Article pages: structured "Why it belongs" editorial read

Per Codex — make /a/<id> feel like Upbeat Bytes has editorial judgment, not just
a summary wrapper. Trust-building, short, not an essay.

* article_summaries gains what_happened / why_matters / why_belongs (+ migration).
* summarize.explain_article: a separate, fallback-able LLM pass producing three
  short notes (parsed from a labelled WHAT/MATTERS/BELONGS format). generate_summary
  now stores them alongside the summary, and tops up older summaries on next view.
  get_explanation returns them only when all three are present.
* API: share_page + /api/summary expose the explanation.
* share.py: renders the three-part section (accent rule) when complete; otherwise
  the single "Why it's here" reason line is the calm fallback. The page polls and
  swaps in both the summary and the section as they cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-09 20:05:26 -04:00
parent 9befbffd94
commit 337dc3f901
6 changed files with 192 additions and 21 deletions
+64 -7
View File
@@ -76,6 +76,39 @@ def summarize_article(client: LocalModelClient, title: str, snippet: str, body_t
return (client.chat_text(messages) or "").strip()[:1200]
_EXPLAIN_SYSTEM = (
"You are the calm editor of a constructive-news site. For the given story write three "
"very short, plain-language notes — 1 to 2 factual sentences each, in your own words, no "
"markdown, no preamble, no quotes, no hype. Use EXACTLY this format and these labels, each "
"on its own line:\n"
"WHAT: <what actually happened — the plain gist>\n"
"MATTERS: <why it matters — the real-world, constructive significance>\n"
"BELONGS: <why it fits a calm, constructive news site — the human benefit, agency, or "
"grounded hope in it>"
)
def _parse_explain(text: str) -> dict:
def grab(label: str) -> str | None:
m = re.search(rf"\b{label}\s*:\s*(.+?)(?=\n\s*[A-Z]+\s*:|\Z)", text or "", re.IGNORECASE | re.DOTALL)
if not m:
return None
val = _WS.sub(" ", m.group(1)).strip().strip("-•* ").strip()
return val[:400] or None
return {"what_happened": grab("WHAT"), "why_matters": grab("MATTERS"), "why_belongs": grab("BELONGS")}
def explain_article(client: LocalModelClient, title: str, snippet: str, body_text: str) -> dict:
"""Three short editorial notes (what happened / why it matters / why it belongs)."""
material = (body_text or snippet or title or "")[:4000]
user = f"Title: {title}\n\nArticle text:\n{material}\n\nWrite the three notes."
text = client.chat_text(
[{"role": "system", "content": _EXPLAIN_SYSTEM}, {"role": "user", "content": user}]
) or ""
return _parse_explain(text)
def get_summary(conn: sqlite3.Connection, article_id: int) -> str | None:
row = conn.execute(
"SELECT summary FROM article_summaries WHERE article_id = ?", (article_id,)
@@ -83,25 +116,49 @@ def get_summary(conn: sqlite3.Connection, article_id: int) -> str | None:
return row["summary"] if row else None
def get_explanation(conn: sqlite3.Connection, article_id: int) -> dict | None:
"""The structured 'Why it belongs' notes — only if all three are present (else
the page falls back to summary + reason_text)."""
row = conn.execute(
"SELECT what_happened, why_matters, why_belongs FROM article_summaries WHERE article_id = ?",
(article_id,),
).fetchone()
if row and row["what_happened"] and row["why_matters"] and row["why_belongs"]:
return dict(row)
return None
def generate_summary(conn: sqlite3.Connection, article_id: int, client: LocalModelClient | None = None) -> str | None:
"""Generate + cache a summary for one article. Returns it, or None if skipped."""
if get_summary(conn, article_id):
return get_summary(conn, article_id) # already cached
"""Generate + cache a summary AND the structured explanation for one article.
Returns the summary, or None if skipped. Idempotent: a fully-cached article
(summary + explanation) is returned as-is; an older summary missing the
explanation is topped up on the next call (so existing pages gain the section).
"""
existing = conn.execute(
"SELECT summary, why_belongs FROM article_summaries WHERE article_id = ?", (article_id,)
).fetchone()
if existing and existing["summary"] and existing["why_belongs"]:
return existing["summary"] # summary + a complete explanation already cached
row = conn.execute(
"SELECT a.title, a.description, a.canonical_url, a.duplicate_of, s.accepted "
"FROM articles a LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
(article_id,),
).fetchone()
if not row or row["duplicate_of"] is not None or not row["accepted"]:
return None
return existing["summary"] if existing else None
client = client or LocalModelClient.from_env()
body = _fetch_text(row["canonical_url"])
summary = summarize_article(client, row["title"], row["description"] or "", body)
summary = existing["summary"] if existing else summarize_article(
client, row["title"], row["description"] or "", body
)
if not summary:
return None
ex = explain_article(client, row["title"], row["description"] or "", body)
conn.execute(
"INSERT OR REPLACE INTO article_summaries (article_id, summary, model) VALUES (?, ?, ?)",
(article_id, summary, client.model),
"INSERT OR REPLACE INTO article_summaries "
"(article_id, summary, what_happened, why_matters, why_belongs, model) VALUES (?, ?, ?, ?, ?, ?)",
(article_id, summary, ex["what_happened"], ex["why_matters"], ex["why_belongs"], client.model),
)
conn.commit()
# Attention-triggered image enrichment: a summarized article is one a reader