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}
-
+
- {#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}
Upbeat Bytes summarizes in its own words and links to the original publisher — it doesn't host the article.
{src_click}
+
{poll}