Local-first Brief: the landing leads with good news from your home
Per the owner's call (overrides the earlier "Brief sacred" stance): when a home is
set, the homepage opens with local good news first, not global. This is the hook —
you land and see awesome stories from YOUR corner first.
- queries.home_brief: local-first highlights (high/medium-confidence near, blended
out to country then world so it's always a full, strong set), preferring already-
summarized stories so the calm read stays rich. Recent window, ranked within tier.
- /api/brief gains a `home` param: private/no-store when set; over-fetches + caps so
dismissal/boundary filtering never thins it; falls back to global top-up if needed.
- Landing UI: a Local <-> Global toggle ("📍 Near you / 🌍 Everywhere") when a home
is set, the calm picker invite when not (dismissible), and Change. Default leads
local; one tap back to the global brief. No home set => exactly today's behavior.
Backend + frontend tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+22
-5
@@ -2186,25 +2186,42 @@ def create_app() -> FastAPI:
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
prefs: str | None = Query(None),
|
||||
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
|
||||
home: str | None = Query(None, max_length=8, description="local-first highlights: 'US' or 'US-NY'"),
|
||||
) -> BriefResponse:
|
||||
# The default highlights are global (date-keyed, no session) → edge-cacheable
|
||||
# so a new visitor's "Gathering the good news…" resolves from their POP, not
|
||||
# a pull to the residential origin. Personal filters stay private.
|
||||
shareable = not prefs and not exclude.strip()
|
||||
# a pull to the residential origin. Personal filters (incl. a home) stay private.
|
||||
home_country = home_state = None
|
||||
if home:
|
||||
parts = home.upper().split("-", 1)
|
||||
home_country = parts[0][:2] or None
|
||||
if home_country == "US" and len(parts) > 1:
|
||||
home_state = parts[1][:2] or None
|
||||
shareable = not prefs and not exclude.strip() and not home_country
|
||||
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
|
||||
fp = prefs_from_json(prefs)
|
||||
now = datetime.now(timezone.utc)
|
||||
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
|
||||
with get_conn() as conn:
|
||||
data = queries.brief(conn, brief_date=date, limit=limit)
|
||||
if home_country:
|
||||
# The reader's home leads the landing: local good news first, blended out
|
||||
# to country/world so it's always a full, sexy set. Over-fetch to survive
|
||||
# dismissal/boundary filtering, then cap to limit.
|
||||
meta = queries.brief(conn, brief_date=date, limit=1)
|
||||
data = {"brief_date": meta["brief_date"], "title": "Close to home", "created_at": meta.get("created_at")}
|
||||
pool = queries.home_brief(conn, home_country, home_state, limit=limit + 12)
|
||||
else:
|
||||
data = queries.brief(conn, brief_date=date, limit=limit)
|
||||
pool = data["items"]
|
||||
# Drop dismissed (replaced-away) items and anything the reader's
|
||||
# boundaries hide; avoid-terms take precedence over curation.
|
||||
items = [a for a in data["items"] if a["id"] not in excl]
|
||||
items = [a for a in pool if a["id"] not in excl]
|
||||
if not fp.is_empty():
|
||||
items = filter_articles(items, fp, now)
|
||||
items = items[:limit] # home mode over-fetches to survive filtering; cap here
|
||||
# Keep the highlights full: if a boundary or a dismissal removed a
|
||||
# story, top up with other readable, boundary-respecting good news
|
||||
# rather than show fewer.
|
||||
# rather than show fewer. (Home mode's home_brief already blends to world.)
|
||||
if len(items) < limit:
|
||||
have = {a["id"] for a in items} | excl
|
||||
pool = queries.feed(
|
||||
|
||||
+8
-2
@@ -213,10 +213,16 @@ def tag_articles(conn: sqlite3.Connection, client: LocalModelClient, limit: int
|
||||
for r in rows:
|
||||
try:
|
||||
store_geo(conn, r["id"], classify_geo(client, r))
|
||||
# Keep live auth/admin writes healthy while the scheduled cycle runs.
|
||||
# Geo classification calls the LLM per article; if we batch commits, the
|
||||
# first stored article opens a write transaction that can stay open while
|
||||
# the next several LLM calls run. That starves login/session writes long
|
||||
# enough to trip SQLite's busy timeout. Commit each successful article so
|
||||
# the writer lock is held for milliseconds, not minutes.
|
||||
conn.commit()
|
||||
tagged += 1
|
||||
except Exception: # noqa: BLE001 — non-fatal, like other cycle steps
|
||||
conn.rollback()
|
||||
errors += 1
|
||||
if (tagged + errors) % 25 == 0:
|
||||
conn.commit()
|
||||
conn.commit()
|
||||
return {"candidates": len(rows), "tagged": tagged, "errors": errors}
|
||||
|
||||
@@ -259,6 +259,51 @@ def reindex_search(conn: sqlite3.Connection) -> int:
|
||||
return conn.execute("SELECT COUNT(*) FROM article_search").fetchone()[0]
|
||||
|
||||
|
||||
def home_brief(conn: sqlite3.Connection, home_country: str, home_state: str | None = None,
|
||||
limit: int = 7, window_days: int = 3) -> list[dict]:
|
||||
"""Local-first landing highlights. Leads with high/medium-confidence local good news,
|
||||
then blends out to your country and the world so the set is always full (never the
|
||||
sad thin-local look), and prefers already-summarized stories so the calm read stays
|
||||
rich. Brief-shaped rows (incl. summary) tagged with a section, best-first within tier.
|
||||
"""
|
||||
if home_state:
|
||||
near = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
||||
"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code = ?))")
|
||||
country = "EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?)"
|
||||
section_case = f"CASE WHEN {near} THEN 0 WHEN {country} THEN 1 ELSE 2 END"
|
||||
section_params = [home_country, home_state, home_country]
|
||||
else:
|
||||
near = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
||||
"WHERE p.article_id = a.id AND p.country_code = ?))")
|
||||
section_case = f"CASE WHEN {near} THEN 0 ELSE 2 END" # no "country" tier without a state
|
||||
section_params = [home_country]
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT {_ARTICLE_COLUMNS},
|
||||
sm.summary AS summary,
|
||||
{section_case} AS section_rank,
|
||||
(sm.summary IS NOT NULL) AS has_summary
|
||||
FROM articles a
|
||||
JOIN sources src ON src.id = a.source_id
|
||||
JOIN article_scores s ON s.article_id = a.id
|
||||
LEFT JOIN article_geo g ON g.article_id = a.id
|
||||
LEFT JOIN article_summaries sm ON sm.article_id = a.id
|
||||
WHERE a.duplicate_of IS NULL AND src.content_visible = 1 AND s.accepted = 1
|
||||
AND a.discovered_at >= datetime('now', ?)
|
||||
ORDER BY section_rank ASC, has_summary DESC, rank_score DESC,
|
||||
COALESCE(a.published_at, a.discovered_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
section_params + [f"-{window_days} days", limit],
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["__section"] = {0: "near", 1: "country", 2: "world"}.get(d.pop("section_rank", 2), "world")
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = 10) -> dict:
|
||||
"""Return a stored daily brief (latest if no date) with its ranked items."""
|
||||
target_date = brief_date or _latest_brief_date(conn)
|
||||
|
||||
Reference in New Issue
Block a user