Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker

The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.

SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
  (a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
  rep (URL stability) -> quality score, so an accepted page never retires to a
  rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
  falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
  policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).

Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
  picker (bundled data, no CDN) for the blurb editor.

Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.

Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-18 11:32:27 -04:00
parent 2dbe73430c
commit 89c0fbe1f6
66 changed files with 6138 additions and 109 deletions
+93 -6
View File
@@ -49,6 +49,7 @@ CLASSIFICATION_SCHEMA = {
"tags",
"reason_code",
"reason_text",
"language",
],
"properties": {
"constructive_score": _SCORE_FIELD,
@@ -64,6 +65,7 @@ CLASSIFICATION_SCHEMA = {
"tags": {"type": "array", "items": {"type": "string", "enum": list(ALLOWED_TAGS)}, "maxItems": MAX_TAGS},
"reason_code": {"type": "string"},
"reason_text": {"type": "string"},
"language": {"type": "string"}, # ISO 639-1 of the article's own text (en, de, es…)
},
}
@@ -104,6 +106,11 @@ Grouping tags — choose ONLY from this controlled vocabulary:
Tag discipline: assign 1-4 tags; prefer fewer, stronger ones; never tag by weak
association; pick tags a reader would reasonably use to find this story later.
Also report `language`: the ISO 639-1 code of the article's OWN text (the title and
description), e.g. "en", "de", "es", "fr". Judge the language of the words, not the
subject. This is detection only — score and accept the story on its merits as usual;
the site decides separately what to do with non-English items.
Return only JSON with this exact shape:
{{
"constructive_score": 0,
@@ -118,7 +125,8 @@ Return only JSON with this exact shape:
"flavor": "one_of_the_allowed_flavors",
"tags": ["one_to_four_allowed_tags"],
"reason_code": "short_snake_case",
"reason_text": "one concise sentence"
"reason_text": "one concise sentence",
"language": "en"
}}
""".format(topics=topics_prompt_block(), flavors=flavors_prompt_block(), tags=tags_prompt_block())
@@ -222,6 +230,60 @@ class LocalModelClient:
"""
return self._raw_content(self._build_payload(messages, None))
def rank_for_social(self, candidates: list[dict]) -> list[dict]:
"""ONE bounded COMPARATIVE pass over a small candidate set (not N calls).
Returns a best-first list of {id, social_score 0-10, why, talking_points,
angle, entities}. Bounded by self.timeout; callers fall back to deterministic
ranking on ANY failure, so the Publishing Desk always works."""
if not candidates:
return []
lines = []
for c in candidates:
summ = " ".join((c.get("summary") or "").split())[:280]
lines.append(f'- id={int(c["id"])} | topic={c.get("topic")} | {c["title"]} :: {summ}')
user = (
"These are constructive-news articles. Compare them as candidates for a SHORT X "
"(Twitter) post from a calm good-news account, and rank best-first by SOCIAL "
"share-worthiness — would someone stop scrolling? That differs from how 'good' the "
"article is.\n\n" + "\n".join(lines) + "\n\n"
'Reply with JSON only, exactly this shape:\n'
'{"ranked": [{"id": <one of the ids above>, "social_score": <0-10>, '
'"why": "one sentence: why it stops the scroll", '
'"talking_points": ["3 short factual points a writer could use"], '
'"angle": "a possible conversational angle", '
'"entities": ["real org/person names mentioned, for tagging"]}]}\n'
"Only use ids from the list above. Order best-first."
)
messages = [
{"role": "system", "content": "You rank constructive news for social sharing. Reply with JSON only."},
{"role": "user", "content": user},
]
data = parse_classifier_json(self.chat_text(messages))
ranked = data.get("ranked") if isinstance(data, dict) else None
if not isinstance(ranked, list):
raise RuntimeError("rank_for_social: missing 'ranked' list")
out = []
for r in ranked:
if not isinstance(r, dict):
continue
try:
rid = int(r.get("id"))
except (TypeError, ValueError):
continue
# Require ACTUAL lists — a model that returns a bare string must not be
# iterated into characters ("fact" → ["f","a","c","t"]).
tp = r.get("talking_points")
ents = r.get("entities")
out.append({
"id": rid,
"social_score": _bounded_int(r.get("social_score")),
"why": str(r.get("why") or "")[:300],
"talking_points": [str(p)[:200] for p in tp][:4] if isinstance(tp, list) else [],
"angle": str(r.get("angle") or "")[:300],
"entities": [str(e)[:80] for e in ents][:8] if isinstance(ents, list) else [],
})
return out
def _raw_content(self, payload: dict) -> str:
body = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json"}
@@ -304,7 +366,29 @@ def parse_classifier_json(content: str) -> dict:
return json.loads(content[start : end + 1])
def _is_english(language: str) -> bool:
"""Conservative: HOLD only when the model clearly reports a non-English language.
Missing/blank/undetermined → treated as English, so a model hiccup never silently
drops genuine English content (the corpus is ~all English today)."""
lang = (language or "").strip().lower()
if not lang or lang in ("und", "unknown", "mul", "zxx"):
return True
return lang == "en" or lang.startswith("en-") or lang.startswith("en_")
def normalize_scores(data: dict, model_name: str) -> dict:
language = str(data.get("language") or "").strip().lower()[:16]
accepted = 1 if bool(data.get("accepted")) else 0
reason_code = str(data.get("reason_code") or "model_no_reason")[:120]
reason_text = str(data.get("reason_text") or "")[:1000]
# Language gate (code disposes): the public feed is English-only for now. A
# non-English article is HELD — never shown — but PRESERVED with a distinct
# reason so it isn't counted as a calm-filter rejection or a source failure, and
# can be revisited when translation support lands (Phase 4 / GDELT).
if not _is_english(language):
accepted = 0
reason_code = "non_english"
reason_text = f"Held — non-English ({language}); awaiting translation support."
return {
"constructive_score": _bounded_int(data.get("constructive_score")),
"cortisol_score": _bounded_int(data.get("cortisol_score")),
@@ -313,12 +397,13 @@ def normalize_scores(data: dict, model_name: str) -> dict:
"human_benefit_score": _bounded_int(data.get("human_benefit_score")),
"novelty_score": _bounded_int(data.get("novelty_score")),
"pr_risk_score": _bounded_int(data.get("pr_risk_score")),
"accepted": 1 if bool(data.get("accepted")) else 0,
"accepted": accepted,
"topic": coerce_topic(data.get("topic")),
"flavor": coerce_flavor(data.get("flavor")),
"tags": coerce_tags(data.get("tags")),
"reason_code": str(data.get("reason_code") or "model_no_reason")[:120],
"reason_text": str(data.get("reason_text") or "")[:1000],
"reason_code": reason_code,
"reason_text": reason_text,
"language": language,
"model_name": model_name,
}
@@ -329,9 +414,9 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
INSERT INTO article_scores (
article_id, constructive_score, cortisol_score, ragebait_score,
agency_score, human_benefit_score, novelty_score, pr_risk_score,
accepted, topic, flavor, reason_code, reason_text, model_name, scored_at
accepted, topic, flavor, reason_code, reason_text, language, model_name, scored_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(article_id) DO UPDATE SET
constructive_score = excluded.constructive_score,
cortisol_score = excluded.cortisol_score,
@@ -345,6 +430,7 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
flavor = excluded.flavor,
reason_code = excluded.reason_code,
reason_text = excluded.reason_text,
language = excluded.language,
model_name = excluded.model_name,
scored_at = CURRENT_TIMESTAMP
""",
@@ -362,6 +448,7 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
scores["flavor"],
scores["reason_code"],
scores["reason_text"],
scores.get("language"),
scores["model_name"],
),
)