diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index 9c65a35..9f1f528 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -43,6 +43,12 @@ let safeHref = $derived( typeof article.url === 'string' && /^https?:\/\//.test(article.url) ? article.url : '#' ); + // Default click → our summary page (the calm gist). Source is one tap further. + let summaryHref = $derived('/a/' + article.id); + function openedSource() { + onview?.(article); + track('open', article.id); + } function act(kind, value) { if (value) onaction?.(kind, value); @@ -87,7 +93,7 @@ data-topic={article.topic ?? ''} > {#if showImage} - + { failed = true; onimageerror?.(); }} /> @@ -108,9 +114,11 @@
{article.source}
-

{article.title}

+

{article.title}

- {#if hero && article.description} + {#if article.summary} +

{article.summary}

+ {:else if hero && article.description}

{article.description}

{/if} @@ -133,10 +141,11 @@ {isSaved ? 'Saved' : 'Save'} {/if} + + {article.paywalled ? 'Full story 🔒' : 'Full story'} + {#if onreplace} - + {/if} @@ -206,6 +215,11 @@ h3 { font-size: 1.18rem; } h3 a:hover { color: var(--accent-deep); } .desc { margin: 2px 0 0; color: #3b4754; } + .summary { margin: 2px 0 0; color: var(--ink); font-size: 0.92rem; } + .tile .summary { + display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 5; + } + .hero .summary, .herotype .summary { font-size: 1rem; } .paywall { margin: 0; font-size: 0.78rem; color: var(--gold); display: inline-flex; align-items: center; gap: 5px; @@ -235,6 +249,11 @@ color: var(--accent-deep); border-bottom-color: var(--accent-soft); } .actions .save.on { color: var(--accent); } + .actions .source { + color: var(--accent-deep); text-decoration: none; font-size: 0.76rem; + border-bottom: 1px dotted var(--accent-soft); + } + .actions .source:hover { border-bottom-color: var(--accent); } .actions .share { color: var(--accent-deep); border-bottom-color: var(--accent-soft); } .sharewrap { position: relative; display: inline-flex; } .sharemenu { diff --git a/goodnews/api.py b/goodnews/api.py index bcd3dfd..b3532ec 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -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,), diff --git a/goodnews/cli.py b/goodnews/cli.py index 2e2b30e..991b5ed 100644 --- a/goodnews/cli.py +++ b/goodnews/cli.py @@ -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) diff --git a/goodnews/queries.py b/goodnews/queries.py index ecc4884..b05e67d 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -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 diff --git a/goodnews/share.py b/goodnews/share.py index 716ad7e..e7457fd 100644 --- a/goodnews/share.py +++ b/goodnews/share.py @@ -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'{escape(t.replace("-", " "))}' + for t in raw_tags.split(",") if t + ) + groupings = f'
{chips}
' if chips else "" + source_short = source.split(" ")[0] if source else "the source" if summary: summary_block = f'

{escape(summary)}

' @@ -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; }} @@ -141,15 +152,27 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)

{escape(title)}

{summary_block}

Why it's here{escape(why)}

+ {groupings}
Read the full story at {escape(source_short)} - Explore more on Upbeat Bytes → + + Explore Upbeat Bytes →

Upbeat Bytes summarizes in its own words and links to the original publisher — it doesn't host the article.

{src_click} + {poll} """ diff --git a/goodnews/summarize.py b/goodnews/summarize.py index 6d40ca4..2fef235 100644 --- a/goodnews/summarize.py +++ b/goodnews/summarize.py @@ -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." )