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:
+9
-5
@@ -886,9 +886,12 @@ def create_app() -> FastAPI:
|
|||||||
if not row or row["duplicate_of"] is not None or not row["accepted"]:
|
if not row or row["duplicate_of"] is not None or not row["accepted"]:
|
||||||
return not_found
|
return not_found
|
||||||
summary = summarize.get_summary(conn, aid)
|
summary = summarize.get_summary(conn, aid)
|
||||||
if not summary:
|
explanation = summarize.get_explanation(conn, aid)
|
||||||
_kick_summary(aid, background_tasks) # generate for next time; page polls
|
if not summary or not explanation:
|
||||||
return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary))
|
_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 -------------------------
|
# --- Privacy-respecting first-party analytics -------------------------
|
||||||
|
|
||||||
@@ -1243,10 +1246,11 @@ def create_app() -> FastAPI:
|
|||||||
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
summary = summarize.get_summary(conn, article_id)
|
summary = summarize.get_summary(conn, article_id)
|
||||||
|
explanation = summarize.get_explanation(conn, article_id)
|
||||||
if summary:
|
if summary:
|
||||||
return {"status": "ready", "summary": summary}
|
return {"status": "ready", "summary": summary, "explanation": explanation}
|
||||||
_kick_summary(article_id, background_tasks)
|
_kick_summary(article_id, background_tasks)
|
||||||
return {"status": "pending", "summary": None}
|
return {"status": "pending", "summary": None, "explanation": None}
|
||||||
|
|
||||||
@app.get("/today", response_class=HTMLResponse)
|
@app.get("/today", response_class=HTMLResponse)
|
||||||
def today_digest() -> HTMLResponse:
|
def today_digest() -> HTMLResponse:
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ CREATE TABLE IF NOT EXISTS user_prefs (
|
|||||||
CREATE TABLE IF NOT EXISTS article_summaries (
|
CREATE TABLE IF NOT EXISTS article_summaries (
|
||||||
article_id INTEGER PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
|
article_id INTEGER PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
|
what_happened TEXT,
|
||||||
|
why_matters TEXT,
|
||||||
|
why_belongs TEXT,
|
||||||
model TEXT,
|
model TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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)")}
|
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
|
||||||
if rep_cols and "message_html" not in rep_cols:
|
if rep_cols and "message_html" not in rep_cols:
|
||||||
conn.execute("ALTER TABLE feedback_replies ADD COLUMN message_html TEXT")
|
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")
|
||||||
|
|||||||
+45
-8
@@ -17,7 +17,8 @@ def _tag(name: str, content: str | None, attr: str = "property") -> str:
|
|||||||
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
|
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
|
||||||
|
|
||||||
|
|
||||||
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"]
|
aid = article["id"]
|
||||||
title = (article.get("title") or "Upbeat Bytes").strip()
|
title = (article.get("title") or "Upbeat Bytes").strip()
|
||||||
why = (article.get("reason_text") or article.get("description")
|
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'<div class="chips">{chips}</div>' if chips else ""
|
groupings = f'<div class="chips">{chips}</div>' if chips else ""
|
||||||
if summary:
|
if summary:
|
||||||
summary_block = f'<p class="summary" id="summary">{escape(summary)}</p>'
|
summary_block = f'<p class="summary" id="summary">{escape(summary)}</p>'
|
||||||
poll = ""
|
|
||||||
else:
|
else:
|
||||||
pending = (
|
pending = (
|
||||||
f"✦ We’re putting together a quick summary — in the meantime, "
|
f"✦ We’re putting together a quick summary — in the meantime, "
|
||||||
f"read the full story at {escape(source)} below."
|
f"read the full story at {escape(source)} below."
|
||||||
)
|
)
|
||||||
summary_block = f'<p class="summary pending" id="summary">{pending}</p>'
|
summary_block = f'<p class="summary pending" id="summary">{pending}</p>'
|
||||||
# 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'<div class="why-row"><span class="lbl">{lbl}</span><p>{escape(text)}</p></div>'
|
||||||
|
|
||||||
|
if explanation:
|
||||||
|
why_block = (
|
||||||
|
'<div class="why" id="why">'
|
||||||
|
+ _why_row("What happened", explanation["what_happened"])
|
||||||
|
+ _why_row("Why it matters", explanation["why_matters"])
|
||||||
|
+ _why_row("Why it belongs here", explanation["why_belongs"])
|
||||||
|
+ '</div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
why_block = f'<div class="why" id="why"><div class="why-row"><span class="lbl">Why it’s here</span><p>{escape(why)}</p></div></div>'
|
||||||
|
|
||||||
|
# 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"""<script>
|
poll = f"""<script>
|
||||||
(function(){{
|
(function(){{
|
||||||
var id={aid}, box=document.getElementById('summary'), n=0;
|
var id={aid}, box=document.getElementById('summary'), n=0;
|
||||||
|
function row(l,t){{
|
||||||
|
var d=document.createElement('div'); d.className='why-row';
|
||||||
|
var s=document.createElement('span'); s.className='lbl'; s.textContent=l;
|
||||||
|
var p=document.createElement('p'); p.textContent=t;
|
||||||
|
d.appendChild(s); d.appendChild(p); return d;
|
||||||
|
}}
|
||||||
function go(){{
|
function go(){{
|
||||||
n++;
|
n++;
|
||||||
fetch('/api/summary/'+id).then(function(r){{return r.json();}}).then(function(d){{
|
fetch('/api/summary/'+id).then(function(r){{return r.json();}}).then(function(d){{
|
||||||
if(d&&d.status==='ready'&&d.summary){{ box.textContent=d.summary; box.className='summary'; }}
|
var done=true;
|
||||||
else if(n<12){{ setTimeout(go,2500); }}
|
if(d&&d.summary){{ box.textContent=d.summary; box.className='summary'; }} else {{ done=false; }}
|
||||||
|
if(d&&d.explanation){{
|
||||||
|
var w=document.getElementById('why');
|
||||||
|
if(w){{ w.innerHTML=''; w.appendChild(row('What happened',d.explanation.what_happened));
|
||||||
|
w.appendChild(row('Why it matters',d.explanation.why_matters));
|
||||||
|
w.appendChild(row('Why it belongs here',d.explanation.why_belongs)); }}
|
||||||
|
}} else {{ done=false; }}
|
||||||
|
if(!done && n<12){{ setTimeout(go,2500); }}
|
||||||
}}).catch(function(){{ if(n<12) setTimeout(go,3000); }});
|
}}).catch(function(){{ if(n<12) setTimeout(go,3000); }});
|
||||||
}}
|
}}
|
||||||
go();
|
go();
|
||||||
@@ -140,9 +173,13 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
|||||||
font-size:1.7rem; line-height:1.2; margin:6px 0 14px; }}
|
font-size:1.7rem; line-height:1.2; margin:6px 0 14px; }}
|
||||||
.summary {{ font-size:1.05rem; color:var(--ink); margin:0 0 18px; }}
|
.summary {{ font-size:1.05rem; color:var(--ink); margin:0 0 18px; }}
|
||||||
.summary.pending {{ color:var(--muted); font-style:italic; }}
|
.summary.pending {{ color:var(--muted); font-style:italic; }}
|
||||||
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px; }}
|
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px;
|
||||||
|
border-left:2px solid var(--accent); padding-left:14px; }}
|
||||||
|
.why-row {{ margin:0 0 12px; }}
|
||||||
|
.why-row:last-child {{ margin-bottom:0; }}
|
||||||
|
.why-row p {{ margin:0; }}
|
||||||
.why .lbl {{ display:block; text-transform:uppercase; letter-spacing:.08em; font-size:.7rem;
|
.why .lbl {{ display:block; text-transform:uppercase; letter-spacing:.08em; font-size:.7rem;
|
||||||
color:var(--muted); margin-bottom:3px; }}
|
color:var(--accent-deep); font-weight:600; margin-bottom:2px; }}
|
||||||
.chips {{ display:flex; flex-wrap:wrap; gap:7px; margin:0 0 22px; }}
|
.chips {{ display:flex; flex-wrap:wrap; gap:7px; margin:0 0 22px; }}
|
||||||
.chip {{ background:#e0eef3; color:var(--accent-deep); border-radius:999px; padding:3px 11px;
|
.chip {{ background:#e0eef3; color:var(--accent-deep); border-radius:999px; padding:3px 11px;
|
||||||
font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; }}
|
font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; }}
|
||||||
@@ -165,7 +202,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
|||||||
{source_html}
|
{source_html}
|
||||||
<h1>{escape(title)}</h1>
|
<h1>{escape(title)}</h1>
|
||||||
{summary_block}
|
{summary_block}
|
||||||
<p class="why"><span class="lbl">Why it's here</span>{escape(why)}</p>
|
{why_block}
|
||||||
{groupings}
|
{groupings}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
|
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
|
||||||
|
|||||||
+64
-7
@@ -76,6 +76,39 @@ def summarize_article(client: LocalModelClient, title: str, snippet: str, body_t
|
|||||||
return (client.chat_text(messages) or "").strip()[:1200]
|
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:
|
def get_summary(conn: sqlite3.Connection, article_id: int) -> str | None:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT summary FROM article_summaries WHERE article_id = ?", (article_id,)
|
"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
|
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:
|
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."""
|
"""Generate + cache a summary AND the structured explanation for one article.
|
||||||
if get_summary(conn, article_id):
|
|
||||||
return get_summary(conn, article_id) # already cached
|
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(
|
row = conn.execute(
|
||||||
"SELECT a.title, a.description, a.canonical_url, a.duplicate_of, s.accepted "
|
"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 = ?",
|
"FROM articles a LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
|
||||||
(article_id,),
|
(article_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row or row["duplicate_of"] is not None or not row["accepted"]:
|
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()
|
client = client or LocalModelClient.from_env()
|
||||||
body = _fetch_text(row["canonical_url"])
|
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:
|
if not summary:
|
||||||
return None
|
return None
|
||||||
|
ex = explain_article(client, row["title"], row["description"] or "", body)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO article_summaries (article_id, summary, model) VALUES (?, ?, ?)",
|
"INSERT OR REPLACE INTO article_summaries "
|
||||||
(article_id, summary, client.model),
|
"(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()
|
conn.commit()
|
||||||
# Attention-triggered image enrichment: a summarized article is one a reader
|
# Attention-triggered image enrichment: a summarized article is one a reader
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from goodnews import summarize, share
|
||||||
|
from goodnews.db import connect, init_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_explain_labels():
|
||||||
|
txt = ("WHAT: A town planted 500 trees.\n"
|
||||||
|
"MATTERS: It cools streets and cuts flooding.\n"
|
||||||
|
"BELONGS: Neighbors did it themselves — local agency and clear benefit.")
|
||||||
|
ex = summarize._parse_explain(txt)
|
||||||
|
assert ex["what_happened"].startswith("A town planted")
|
||||||
|
assert "cools streets" in ex["why_matters"]
|
||||||
|
assert "local agency" in ex["why_belongs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_explain_partial():
|
||||||
|
ex = summarize._parse_explain("WHAT: only this")
|
||||||
|
assert ex["what_happened"] == "only this" and ex["why_matters"] is None and ex["why_belongs"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_article(c):
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url) VALUES (1,'S','http://s/f')")
|
||||||
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (1,1,'http://a/1','Town plants trees','h')")
|
||||||
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (1,1)")
|
||||||
|
c.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_explanation_requires_all_three(tmp_path):
|
||||||
|
c = connect(str(tmp_path / "e.db")); init_db(c); _seed_article(c)
|
||||||
|
c.execute("INSERT INTO article_summaries (article_id,summary,what_happened) VALUES (1,'s','w')"); c.commit()
|
||||||
|
assert summarize.get_explanation(c, 1) is None # partial → fall back
|
||||||
|
c.execute("UPDATE article_summaries SET why_matters='m', why_belongs='b' WHERE article_id=1"); c.commit()
|
||||||
|
assert summarize.get_explanation(c, 1) == {"what_happened": "w", "why_matters": "m", "why_belongs": "b"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_summary_stores_explanation(tmp_path, monkeypatch):
|
||||||
|
c = connect(str(tmp_path / "g.db")); init_db(c); _seed_article(c)
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
model = "test"
|
||||||
|
def chat_text(self, messages):
|
||||||
|
if "three" in messages[0]["content"].lower():
|
||||||
|
return "WHAT: trees planted.\nMATTERS: cooler streets.\nBELONGS: local agency, clear benefit."
|
||||||
|
return "A town planted trees, cooling streets."
|
||||||
|
|
||||||
|
monkeypatch.setattr(summarize, "_fetch_text", lambda url: "body text")
|
||||||
|
s = summarize.generate_summary(c, 1, client=FakeClient())
|
||||||
|
assert s and "planted trees" in s
|
||||||
|
ex = summarize.get_explanation(c, 1)
|
||||||
|
assert "agency" in ex["why_belongs"] and "cooler streets" in ex["why_matters"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_renders_structured_or_fallback():
|
||||||
|
art = {"id": 1, "title": "T", "source_name": "Src", "source_id": 1,
|
||||||
|
"canonical_url": "http://x", "reason_text": "calm reason", "tags": ""}
|
||||||
|
full = share.render_share_page(art, "http://b", summary="sum",
|
||||||
|
explanation={"what_happened": "WH", "why_matters": "WM", "why_belongs": "WB"})
|
||||||
|
assert "What happened" in full and "Why it matters" in full and "Why it belongs here" in full
|
||||||
|
assert "WH" in full and "WB" in full
|
||||||
|
assert "fetch('/api/summary/'" not in full # both cached → no poll needed
|
||||||
|
# no explanation → the single "Why it’s here" reason line is the calm fallback
|
||||||
|
fb = share.render_share_page(art, "http://b", summary="sum", explanation=None)
|
||||||
|
assert "Why it’s here" in fb and "calm reason" in fb
|
||||||
@@ -21,6 +21,8 @@ def test_generate_caches_summary(tmp_path, monkeypatch):
|
|||||||
c = _db(tmp_path / "t.sqlite3")
|
c = _db(tmp_path / "t.sqlite3")
|
||||||
monkeypatch.setattr(summarize, "_fetch_text", lambda url: "Full article body about voles.")
|
monkeypatch.setattr(summarize, "_fetch_text", lambda url: "Full article body about voles.")
|
||||||
monkeypatch.setattr(summarize, "summarize_article", lambda client, t, s, b: "Our own short summary.")
|
monkeypatch.setattr(summarize, "summarize_article", lambda client, t, s, b: "Our own short summary.")
|
||||||
|
monkeypatch.setattr(summarize, "explain_article",
|
||||||
|
lambda client, t, s, b: {"what_happened": "w", "why_matters": "m", "why_belongs": "b"})
|
||||||
# avoid constructing a real LLM client
|
# avoid constructing a real LLM client
|
||||||
monkeypatch.setattr(summarize.LocalModelClient, "from_env", classmethod(lambda cls: SimpleNamespace(model="m")))
|
monkeypatch.setattr(summarize.LocalModelClient, "from_env", classmethod(lambda cls: SimpleNamespace(model="m")))
|
||||||
out = summarize.generate_summary(c, 1)
|
out = summarize.generate_summary(c, 1)
|
||||||
@@ -50,7 +52,7 @@ def test_summary_endpoint_pending_then_ready(tmp_path, monkeypatch):
|
|||||||
first = tc.get("/api/summary/1").json()
|
first = tc.get("/api/summary/1").json()
|
||||||
assert first["status"] == "pending"
|
assert first["status"] == "pending"
|
||||||
# TestClient runs the background task; the next call sees the cache
|
# TestClient runs the background task; the next call sees the cache
|
||||||
assert tc.get("/api/summary/1").json() == {"status": "ready", "summary": "Cached summary."}
|
assert tc.get("/api/summary/1").json() == {"status": "ready", "summary": "Cached summary.", "explanation": None}
|
||||||
# and the share page now embeds it (no pending poll)
|
# and the share page now embeds it (no pending poll)
|
||||||
html = tc.get("/a/1").text
|
html = tc.get("/a/1").text
|
||||||
assert "Cached summary." in html
|
assert "Cached summary." in html
|
||||||
|
|||||||
Reference in New Issue
Block a user