Summary briefing layer: Today pre-summarized, /a is the canonical read
Make summaries the core reading experience (summary-first, source-forward): - Cycle pre-warms summaries for Today's 7 (idempotent → only new ones hit the LLM). - /api/brief items carry their cached summary; Today cards (hero + tiles) show it inline, so Today reads as a calm briefing. - Card title/image now open the /a summary page (the canonical artifact), with a visible "Full story" link straight to the source on every card (the escape hatch). - /a gains related-grouping chips + a Copy-link/share control. - Tighten the summary prompt: original, factual, no quotations / no close paraphrase. Long tail stays lazy+cached. No article bodies stored. 129 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -243,11 +243,13 @@ class Article(BaseModel):
|
||||
rank: int | None = None # position within a brief, when applicable
|
||||
paywalled: bool = False
|
||||
tags: list[str] = []
|
||||
summary: str | None = None # our own cached summary (present on the brief)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: dict) -> "Article":
|
||||
raw_tags = row.get("tags")
|
||||
return cls(
|
||||
summary=row.get("summary"),
|
||||
id=row["id"],
|
||||
title=row["title"],
|
||||
description=row.get("description"),
|
||||
@@ -693,7 +695,8 @@ def create_app() -> FastAPI:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT a.id, a.title, a.description, a.image_url, a.canonical_url, "
|
||||
"a.duplicate_of, src.name AS source_name, s.reason_text, s.accepted "
|
||||
"a.duplicate_of, src.name AS source_name, s.reason_text, s.accepted, "
|
||||
"(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags "
|
||||
"FROM articles a JOIN sources src ON src.id = a.source_id "
|
||||
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
|
||||
(aid,),
|
||||
|
||||
@@ -10,6 +10,7 @@ from .briefs import build_daily_brief, show_brief
|
||||
from .db import connect, init_db
|
||||
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
|
||||
from .enrich import enrich_brief_images
|
||||
from .summarize import generate_summary, get_summary
|
||||
from .feeds import (
|
||||
fetch_feed,
|
||||
parse_feed,
|
||||
@@ -452,6 +453,24 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
||||
except Exception as exc:
|
||||
print(f"brief: skipped ({exc})")
|
||||
|
||||
# Pre-warm summaries for today's brief so Today reads as a calm briefing.
|
||||
# Idempotent: cached items are skipped, so this only hits the LLM for new ones.
|
||||
try:
|
||||
client = llm_client_from_args(args)
|
||||
ids = [r[0] for r in conn.execute(
|
||||
"SELECT bi.article_id FROM daily_briefs b JOIN daily_brief_items bi "
|
||||
"ON bi.brief_id = b.id WHERE b.brief_date = ?", (today,)
|
||||
)]
|
||||
made = 0
|
||||
for aid in ids:
|
||||
had = get_summary(conn, aid)
|
||||
generate_summary(conn, aid, client=client)
|
||||
if not had and get_summary(conn, aid):
|
||||
made += 1
|
||||
print(f"summaries: {made} new (of {len(ids)} brief items)")
|
||||
except Exception as exc:
|
||||
print(f"summaries: skipped ({exc})")
|
||||
|
||||
if not args.no_review:
|
||||
try:
|
||||
flagged = review_sources(conn)
|
||||
|
||||
+2
-1
@@ -133,7 +133,8 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
||||
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT bi.rank, bi.selection_reason, {_ARTICLE_COLUMNS}
|
||||
SELECT bi.rank, bi.selection_reason, {_ARTICLE_COLUMNS},
|
||||
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
||||
FROM daily_briefs b
|
||||
JOIN daily_brief_items bi ON bi.brief_id = b.id
|
||||
JOIN articles a ON a.id = bi.article_id
|
||||
|
||||
+25
-2
@@ -49,6 +49,13 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
||||
if image else ""
|
||||
)
|
||||
|
||||
raw_tags = (article.get("tags") or "")
|
||||
chips = "".join(
|
||||
f'<span class="chip">{escape(t.replace("-", " "))}</span>'
|
||||
for t in raw_tags.split(",") if t
|
||||
)
|
||||
groupings = f'<div class="chips">{chips}</div>' if chips else ""
|
||||
|
||||
source_short = source.split(" ")[0] if source else "the source"
|
||||
if summary:
|
||||
summary_block = f'<p class="summary" id="summary">{escape(summary)}</p>'
|
||||
@@ -122,11 +129,15 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
||||
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px; }}
|
||||
.why .lbl {{ display:block; text-transform:uppercase; letter-spacing:.08em; font-size:.7rem;
|
||||
color:var(--muted); margin-bottom:3px; }}
|
||||
.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;
|
||||
font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; }}
|
||||
.actions {{ display:flex; flex-wrap:wrap; gap:12px; align-items:center; }}
|
||||
.primary {{ background:var(--accent); color:#fff; text-decoration:none; font-weight:600;
|
||||
padding:12px 20px; border-radius:999px; display:inline-block; }}
|
||||
.primary:hover {{ background:var(--accent-deep); }}
|
||||
.secondary {{ color:var(--accent-deep); text-decoration:none; font-size:.92rem; }}
|
||||
.secondary {{ color:var(--accent-deep); text-decoration:none; font-size:.92rem;
|
||||
background:none; border:none; cursor:pointer; font-family:inherit; padding:0; }}
|
||||
.secondary:hover {{ text-decoration:underline; }}
|
||||
.note {{ color:var(--muted); font-size:.8rem; margin-top:22px; }}
|
||||
</style>
|
||||
@@ -141,15 +152,27 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
||||
<h1>{escape(title)}</h1>
|
||||
{summary_block}
|
||||
<p class="why"><span class="lbl">Why it's here</span>{escape(why)}</p>
|
||||
{groupings}
|
||||
<div class="actions">
|
||||
<a class="primary" href="{escape(src_url)}" rel="noopener" data-src-click>Read the full story at {escape(source_short)}</a>
|
||||
<a class="secondary" href="/">Explore more on Upbeat Bytes →</a>
|
||||
<button class="secondary" type="button" data-share>Copy link</button>
|
||||
<a class="secondary" href="/">Explore Upbeat Bytes →</a>
|
||||
</div>
|
||||
<p class="note">Upbeat Bytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
{src_click}
|
||||
<script>
|
||||
(function(){{
|
||||
var s=document.querySelector('[data-share]'); if(!s) return;
|
||||
s.addEventListener('click',function(){{
|
||||
var url=location.href;
|
||||
if(navigator.share){{ navigator.share({{title:document.title,url:url}}).catch(function(){{}}); }}
|
||||
else if(navigator.clipboard){{ navigator.clipboard.writeText(url).then(function(){{ s.textContent='Copied!'; setTimeout(function(){{s.textContent='Copy link';}},1500); }}); }}
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
{poll}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -26,10 +26,11 @@ _TAGS = re.compile(rb"<[^>]+>")
|
||||
_WS = re.compile(r"\s+")
|
||||
|
||||
_SYSTEM = (
|
||||
"You write a short, original summary of a news story for a calm, constructive news "
|
||||
"site. Use your own words — never copy sentences from the source. 2 to 4 plain "
|
||||
"sentences: what happened and why it's encouraging. No preamble, no markdown, no "
|
||||
"headline — just the summary."
|
||||
"You write a short, ORIGINAL summary of a news story for a calm, constructive news "
|
||||
"site. Summarize the underlying FACTS in your own words. Do NOT quote, and do not "
|
||||
"closely paraphrase the article's sentences — no lifted phrases. 2 to 4 plain "
|
||||
"sentences covering what happened and why it's encouraging. No preamble, no markdown, "
|
||||
"no headline, no opinion — just the factual summary."
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user