diff --git a/goodnews/api.py b/goodnews/api.py index 01f2caf..d588e0c 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -886,9 +886,12 @@ def create_app() -> FastAPI: if not row or row["duplicate_of"] is not None or not row["accepted"]: return not_found summary = summarize.get_summary(conn, aid) - if not summary: - _kick_summary(aid, background_tasks) # generate for next time; page polls - return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary)) + explanation = summarize.get_explanation(conn, aid) + if not summary or not explanation: + _kick_summary(aid, background_tasks) # generate/top-up for next time; page polls + return HTMLResponse( + share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary, explanation=explanation) + ) # --- Privacy-respecting first-party analytics ------------------------- @@ -1243,10 +1246,11 @@ def create_app() -> FastAPI: def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict: with get_conn() as conn: summary = summarize.get_summary(conn, article_id) + explanation = summarize.get_explanation(conn, article_id) if summary: - return {"status": "ready", "summary": summary} + return {"status": "ready", "summary": summary, "explanation": explanation} _kick_summary(article_id, background_tasks) - return {"status": "pending", "summary": None} + return {"status": "pending", "summary": None, "explanation": None} @app.get("/today", response_class=HTMLResponse) def today_digest() -> HTMLResponse: diff --git a/goodnews/db.py b/goodnews/db.py index 2e802c1..6e24356 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -208,6 +208,9 @@ CREATE TABLE IF NOT EXISTS user_prefs ( CREATE TABLE IF NOT EXISTS article_summaries ( article_id INTEGER PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE, summary TEXT NOT NULL, + what_happened TEXT, + why_matters TEXT, + why_belongs TEXT, model TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -363,3 +366,9 @@ def _migrate(conn: sqlite3.Connection) -> None: rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")} if rep_cols and "message_html" not in rep_cols: conn.execute("ALTER TABLE feedback_replies ADD COLUMN message_html TEXT") + + # article_summaries: structured "Why it belongs" fields added later. + sum_cols = {row["name"] for row in conn.execute("PRAGMA table_info(article_summaries)")} + for column in ("what_happened", "why_matters", "why_belongs"): + if sum_cols and column not in sum_cols: + conn.execute(f"ALTER TABLE article_summaries ADD COLUMN {column} TEXT") diff --git a/goodnews/share.py b/goodnews/share.py index 2147c05..0bdd53c 100644 --- a/goodnews/share.py +++ b/goodnews/share.py @@ -17,7 +17,8 @@ def _tag(name: str, content: str | None, attr: str = "property") -> str: return f'' -def render_share_page(article: dict, base_url: str, summary: str | None = None) -> str: +def render_share_page(article: dict, base_url: str, summary: str | None = None, + explanation: dict | None = None) -> str: aid = article["id"] title = (article.get("title") or "Upbeat Bytes").strip() why = (article.get("reason_text") or article.get("description") @@ -63,22 +64,54 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None) groupings = f'
{chips}
' if chips else "" if summary: summary_block = f'

{escape(summary)}

' - poll = "" else: pending = ( f"✦ We’re putting together a quick summary — in the meantime, " f"read the full story at {escape(source)} below." ) summary_block = f'

{pending}

' - # Quietly poll; swap the real summary in the moment it's cached. + + # The structured "Why it belongs" editorial section — only when the LLM gave a + # clean three-part read; otherwise the single reason line is the calm fallback. + def _why_row(lbl: str, text: str) -> str: + return f'
{lbl}

{escape(text)}

' + + if explanation: + why_block = ( + '
' + + _why_row("What happened", explanation["what_happened"]) + + _why_row("Why it matters", explanation["why_matters"]) + + _why_row("Why it belongs here", explanation["why_belongs"]) + + '
' + ) + else: + why_block = f'
Why it’s here

{escape(why)}

' + + # Quietly poll until BOTH the summary and the structured read are cached, then + # swap each in (older summaries get topped up with the section on first view). + poll = "" + if not summary or not explanation: poll = f"""