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:
+93
-6
@@ -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"],
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user