Scope dial v2: Nearby / Region / Country / World radius on the homepage
Codex-approved evolution: the reader controls the "emotional radius" of the landing. - Census-region "Regional" grain (geo.region_of / region_states). Scope-aware tiering (queries.home_tiers): closest->widest lead, confidence-gated on state + region, never a hard filter — blends outward so the set is always full. 'world' = the global brief. - queries.home_brief takes a scope; /api/brief gains a scope param (nearby|region| country|world). Country-only / non-US homes collapse to country. - Homepage dial replaces the 2-button toggle: adaptive stops (4 with a US state, else Country/World), persisted scope, "Good news closest first" framing. Concrete, soft section labels (Around New Jersey / Across the Northeast / Across the US / Around the world) so the reader sees the dial worked. Backend 366 + frontend tests green. (Latest feed still on v1 local-first; aligning it to the dial is the immediate follow-up.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,9 @@
|
|||||||
let homeValue = $state('');
|
let homeValue = $state('');
|
||||||
let homePromptDismissed = $state(false);
|
let homePromptDismissed = $state(false);
|
||||||
let feedNextOffset = $state(null);
|
let feedNextOffset = $state(null);
|
||||||
let showGlobalBrief = $state(false); // toggle: see the global brief even with a home set
|
// Scope dial: the reader's "emotional radius" — nearby | region | country | world.
|
||||||
|
// Persisted; default nearby. 'world' = the global brief/feed (no geo lead).
|
||||||
|
let homeScope = $state('nearby');
|
||||||
const homeActive = () => selected === 'latest' && !!homeValue;
|
const homeActive = () => selected === 'latest' && !!homeValue;
|
||||||
let showSignIn = $state(false);
|
let showSignIn = $state(false);
|
||||||
let showSaved = $state(false); // Saved flyout
|
let showSaved = $state(false); // Saved flyout
|
||||||
@@ -249,7 +251,7 @@
|
|||||||
// Instant-paint and the merge only reuse a saved brief when this still matches,
|
// Instant-paint and the merge only reuse a saved brief when this still matches,
|
||||||
// so a boundary change can never briefly resurface content it should now hide.
|
// so a boundary change can never briefly resurface content it should now hide.
|
||||||
function briefSig() {
|
function briefSig() {
|
||||||
const h = homeValue && !showGlobalBrief ? homeValue : '';
|
const h = homeValue && homeScope !== 'world' ? `${homeValue}:${homeScope}` : '';
|
||||||
return P.param(prefs.data) + '|' + Array.from(dismissed).sort().join(',') + '|h:' + h;
|
return P.param(prefs.data) + '|' + Array.from(dismissed).sort().join(',') + '|h:' + h;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +259,9 @@
|
|||||||
const q = P.param(prefs.data);
|
const q = P.param(prefs.data);
|
||||||
const ex = Array.from(dismissed).join(',');
|
const ex = Array.from(dismissed).join(',');
|
||||||
let fetched;
|
let fetched;
|
||||||
const homeq = homeValue && !showGlobalBrief ? `&home=${encodeURIComponent(homeValue)}` : '';
|
// Only personalize when a home is set and the dial isn't on 'world' (global).
|
||||||
|
const homeq = homeValue && homeScope !== 'world'
|
||||||
|
? `&home=${encodeURIComponent(homeValue)}&scope=${homeScope}` : '';
|
||||||
try {
|
try {
|
||||||
fetched = await getJSON(`/api/brief?limit=7${homeq}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
|
fetched = await getJSON(`/api/brief?limit=7${homeq}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -501,32 +505,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Closer to Home: opt-in, localStorage-only, easy to clear ---
|
// --- Closer to Home: opt-in, localStorage-only, easy to clear ---
|
||||||
function setHome(v) {
|
function reloadHomeViews() {
|
||||||
homeValue = v || '';
|
if (selected === 'today') loadToday(true);
|
||||||
showGlobalBrief = false; // a fresh home choice leads with local
|
|
||||||
try { v ? localStorage.setItem('goodnews:home', v) : localStorage.removeItem('goodnews:home'); } catch { /* ignore */ }
|
|
||||||
if (selected === 'today') loadToday(true); // re-lead the landing with local
|
|
||||||
else if (selected === 'latest') loadView('latest', true);
|
else if (selected === 'latest') loadView('latest', true);
|
||||||
}
|
}
|
||||||
// The landing's Local ⟷ Global toggle (only meaningful with a home set).
|
function persistScope() { try { localStorage.setItem('goodnews:homeScope', homeScope); } catch { /* ignore */ } }
|
||||||
function setBriefScope(global) {
|
function setHome(v) {
|
||||||
showGlobalBrief = global;
|
homeValue = v || '';
|
||||||
if (selected === 'today') loadToday(true);
|
// No US state -> nearby/region don't apply; collapse the dial to country.
|
||||||
|
if (!String(v).includes('-') && (homeScope === 'nearby' || homeScope === 'region')) homeScope = 'country';
|
||||||
|
try { v ? localStorage.setItem('goodnews:home', v) : localStorage.removeItem('goodnews:home'); } catch { /* ignore */ }
|
||||||
|
persistScope();
|
||||||
|
reloadHomeViews();
|
||||||
}
|
}
|
||||||
|
function setScope(s) { homeScope = s; persistScope(); reloadHomeViews(); }
|
||||||
function clearHome() { setHome(''); }
|
function clearHome() { setHome(''); }
|
||||||
function dismissHomePrompt() {
|
function dismissHomePrompt() {
|
||||||
homePromptDismissed = true;
|
homePromptDismissed = true;
|
||||||
try { localStorage.setItem('goodnews:homeDismissed', '1'); } catch { /* ignore */ }
|
try { localStorage.setItem('goodnews:homeDismissed', '1'); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
// Section header label for a feed item's tier (only rendered when the tier changes).
|
|
||||||
function sectionLabel(key) {
|
|
||||||
if (key === 'near') return homeState ? 'Near you' : 'Close to home';
|
|
||||||
if (key === 'country') return 'Elsewhere in your country';
|
|
||||||
if (key === 'world') return 'Around the world';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
let homeCountry = $derived(homeValue ? homeValue.split('-')[0] : '');
|
let homeCountry = $derived(homeValue ? homeValue.split('-')[0] : '');
|
||||||
let homeState = $derived(homeValue.includes('-') ? homeValue.split('-')[1] : '');
|
let homeState = $derived(homeValue.includes('-') ? homeValue.split('-')[1] : '');
|
||||||
|
// US census regions (mirror of the backend) — for concrete section labels.
|
||||||
|
const US_REGIONS = {
|
||||||
|
Northeast: ['CT','ME','MA','NH','RI','VT','NJ','NY','PA'],
|
||||||
|
Midwest: ['IL','IN','MI','OH','WI','IA','KS','MN','MO','NE','ND','SD'],
|
||||||
|
South: ['DE','FL','GA','MD','NC','SC','VA','DC','WV','AL','KY','MS','TN','AR','LA','OK','TX'],
|
||||||
|
West: ['AZ','CO','ID','MT','NV','NM','UT','WY','AK','CA','HI','OR','WA'],
|
||||||
|
};
|
||||||
|
let homeStateName = $derived(homeState ? (US_STATES.find(([c]) => c === homeState)?.[1] ?? homeState) : '');
|
||||||
|
let homeRegionName = $derived(homeState ? (Object.keys(US_REGIONS).find((r) => US_REGIONS[r].includes(homeState)) ?? '') : '');
|
||||||
|
let homeCountryLabel = $derived(homeCountry === 'US' ? 'the US' : homeCountry === 'GB' ? 'the UK'
|
||||||
|
: (HOME_COUNTRIES.find(([c]) => c === homeCountry)?.[1] ?? homeCountry));
|
||||||
|
// The dial's stops adapt to what's entered: full radius for a US state, else Country/World.
|
||||||
|
let scopeStops = $derived(homeState
|
||||||
|
? [['nearby', 'Nearby'], ['region', 'Region'], ['country', 'Country'], ['world', 'World']]
|
||||||
|
: [['country', 'Country'], ['world', 'World']]);
|
||||||
|
// Concrete, soft section labels — honest place names so the reader sees the dial worked.
|
||||||
|
function sectionLabel(key) {
|
||||||
|
if (key === 'state') return homeStateName ? `Around ${homeStateName}` : 'Near you';
|
||||||
|
if (key === 'region') return homeRegionName ? `Across the ${homeRegionName}` : 'Your region';
|
||||||
|
if (key === 'country') return `Across ${homeCountryLabel}`;
|
||||||
|
if (key === 'world') return 'Around the world';
|
||||||
|
if (key === 'near') return homeStateName ? `Around ${homeStateName}` : 'Near you'; // legacy feed key
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// Home picker. Countries are the well-covered ones (matches backend normalization);
|
// Home picker. Countries are the well-covered ones (matches backend normalization);
|
||||||
// US gets state granularity. Kept short on purpose — calm, not a config wall.
|
// US gets state granularity. Kept short on purpose — calm, not a config wall.
|
||||||
@@ -565,6 +588,8 @@
|
|||||||
try {
|
try {
|
||||||
homeValue = localStorage.getItem('goodnews:home') || '';
|
homeValue = localStorage.getItem('goodnews:home') || '';
|
||||||
homePromptDismissed = localStorage.getItem('goodnews:homeDismissed') === '1';
|
homePromptDismissed = localStorage.getItem('goodnews:homeDismissed') === '1';
|
||||||
|
homeScope = localStorage.getItem('goodnews:homeScope') || 'nearby';
|
||||||
|
if (!homeValue.includes('-') && (homeScope === 'nearby' || homeScope === 'region')) homeScope = 'country';
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
refreshAuth();
|
refreshAuth();
|
||||||
// trackVisit() now fires once in the global layout (covers every landing page).
|
// trackVisit() now fires once in the global layout (covers every landing page).
|
||||||
@@ -706,17 +731,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if homeValue}
|
{:else if homeValue}
|
||||||
<div class="briefscope rise">
|
<div class="scopedial rise">
|
||||||
<button class="bs-btn" class:on={!showGlobalBrief} onclick={() => setBriefScope(false)}>📍 Near you</button>
|
<span class="sd-label">Good news closest first</span>
|
||||||
<button class="bs-btn" class:on={showGlobalBrief} onclick={() => setBriefScope(true)}>🌍 Everywhere</button>
|
<div class="sd-stops">
|
||||||
<button class="linkish bs-change" onclick={openHomeEditor}>Change</button>
|
{#each scopeStops as [s, label] (s)}
|
||||||
|
<button class="sd-btn" class:on={homeScope === s} onclick={() => setScope(s)}>{label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="linkish sd-change" onclick={openHomeEditor}>Change home</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<section class="rise">
|
<section class="rise">
|
||||||
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} onimageerror={heroImageFailed} />
|
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} onimageerror={heroImageFailed} />
|
||||||
{#if restArticles.length}
|
{#if restArticles.length}
|
||||||
<div class="grid rest">
|
<div class="grid rest">
|
||||||
{#each restArticles as a (a.id)}
|
{#each restArticles as a, i (a.id)}
|
||||||
|
{#if a.section && a.section !== restArticles[i - 1]?.section && a.section !== heroArticle?.section}
|
||||||
|
<h3 class="feed-section">{sectionLabel(a.section)}</h3>
|
||||||
|
{/if}
|
||||||
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
|
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -1008,11 +1040,14 @@
|
|||||||
.linkish { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.86rem;
|
.linkish { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.86rem;
|
||||||
cursor: pointer; text-decoration: underline; padding: 0; }
|
cursor: pointer; text-decoration: underline; padding: 0; }
|
||||||
.homebar { font-size: 0.86rem; color: var(--muted); margin: 0 0 16px; }
|
.homebar { font-size: 0.86rem; color: var(--muted); margin: 0 0 16px; }
|
||||||
.briefscope { display: flex; gap: 8px; align-items: center; margin: 0 0 16px; }
|
.scopedial { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 0 0 16px; }
|
||||||
.bs-btn { font: inherit; font-size: 0.88rem; font-weight: 600; padding: 7px 16px; border: 1px solid var(--line);
|
.sd-label { font-size: 0.82rem; color: var(--muted); }
|
||||||
border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
|
.sd-stops { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
|
||||||
.bs-btn.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-deep); }
|
.sd-btn { font: inherit; font-size: 0.85rem; font-weight: 600; padding: 6px 14px; border: none;
|
||||||
.bs-change { margin-left: auto; }
|
background: var(--bg); color: var(--ink); cursor: pointer; border-right: 1px solid var(--line); }
|
||||||
|
.sd-stops .sd-btn:last-child { border-right: none; }
|
||||||
|
.sd-btn.on { background: var(--accent-soft); color: var(--accent-deep); }
|
||||||
|
.sd-change { margin-left: auto; }
|
||||||
.feed-section { grid-column: 1 / -1; margin: 8px 0 2px; font-family: var(--label); font-size: 0.78rem;
|
.feed-section { grid-column: 1 / -1; margin: 8px 0 2px; font-family: var(--label); font-size: 0.78rem;
|
||||||
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||||
.grid > .feed-section:first-child { margin-top: 0; }
|
.grid > .feed-section:first-child { margin-top: 0; }
|
||||||
|
|||||||
+6
-5
@@ -2187,6 +2187,7 @@ def create_app() -> FastAPI:
|
|||||||
prefs: str | None = Query(None),
|
prefs: str | None = Query(None),
|
||||||
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
|
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'"),
|
home: str | None = Query(None, max_length=8, description="local-first highlights: 'US' or 'US-NY'"),
|
||||||
|
scope: str = Query("nearby", pattern="^(nearby|region|country|world)$", description="radius dial"),
|
||||||
) -> BriefResponse:
|
) -> BriefResponse:
|
||||||
# The default highlights are global (date-keyed, no session) → edge-cacheable
|
# 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
|
# so a new visitor's "Gathering the good news…" resolves from their POP, not
|
||||||
@@ -2203,13 +2204,13 @@ def create_app() -> FastAPI:
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
|
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
if home_country:
|
if home_country and scope != "world":
|
||||||
# The reader's home leads the landing: local good news first, blended out
|
# The reader's home + scope dial lead the landing: closest-first good news,
|
||||||
# to country/world so it's always a full, sexy set. Over-fetch to survive
|
# blended outward so it's always a full set. Over-fetch to survive dismissal/
|
||||||
# dismissal/boundary filtering, then cap to limit.
|
# boundary filtering, then cap to limit. scope='world' = the global brief.
|
||||||
meta = queries.brief(conn, brief_date=date, limit=1)
|
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")}
|
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)
|
pool = queries.home_brief(conn, home_country, home_state, scope=scope, limit=limit + 12)
|
||||||
else:
|
else:
|
||||||
data = queries.brief(conn, brief_date=date, limit=limit)
|
data = queries.brief(conn, brief_date=date, limit=limit)
|
||||||
pool = data["items"]
|
pool = data["items"]
|
||||||
|
|||||||
@@ -38,6 +38,34 @@ US_STATES = {
|
|||||||
"district of columbia": "DC", "washington dc": "DC", "washington d c": "DC",
|
"district of columbia": "DC", "washington dc": "DC", "washington d c": "DC",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# US Census Bureau regions — the "Regional" grain for the scope dial. Standard,
|
||||||
|
# explainable, not arbitrary. DC sits in the South (South Atlantic) per Census.
|
||||||
|
US_REGIONS = {
|
||||||
|
"Northeast": {"CT", "ME", "MA", "NH", "RI", "VT", "NJ", "NY", "PA"},
|
||||||
|
"Midwest": {"IL", "IN", "MI", "OH", "WI", "IA", "KS", "MN", "MO", "NE", "ND", "SD"},
|
||||||
|
"South": {"DE", "FL", "GA", "MD", "NC", "SC", "VA", "DC", "WV", "AL", "KY", "MS",
|
||||||
|
"TN", "AR", "LA", "OK", "TX"},
|
||||||
|
"West": {"AZ", "CO", "ID", "MT", "NV", "NM", "UT", "WY", "AK", "CA", "HI", "OR", "WA"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def region_of(state_code: str | None) -> str | None:
|
||||||
|
"""The Census region name containing a US state code, or None."""
|
||||||
|
if not state_code:
|
||||||
|
return None
|
||||||
|
sc = state_code.upper()
|
||||||
|
for name, states in US_REGIONS.items():
|
||||||
|
if sc in states:
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def region_states(state_code: str | None) -> list[str]:
|
||||||
|
"""Sorted state codes in the same Census region as a state (incl. it)."""
|
||||||
|
name = region_of(state_code)
|
||||||
|
return sorted(US_REGIONS[name]) if name else []
|
||||||
|
|
||||||
|
|
||||||
# Common countries + aliases (extensible). Anything not here returns None -> we drop
|
# Common countries + aliases (extensible). Anything not here returns None -> we drop
|
||||||
# the country rather than store garbage. breadth still captures national/global, etc.
|
# the country rather than store garbage. breadth still captures national/global, etc.
|
||||||
COUNTRY_TO_ISO = {
|
COUNTRY_TO_ISO = {
|
||||||
|
|||||||
+54
-19
@@ -259,24 +259,58 @@ def reindex_search(conn: sqlite3.Connection) -> int:
|
|||||||
return conn.execute("SELECT COUNT(*) FROM article_search").fetchone()[0]
|
return conn.execute("SELECT COUNT(*) FROM article_search").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
def home_brief(conn: sqlite3.Connection, home_country: str, home_state: str | None = None,
|
# Scope dial: the reader's "emotional radius". Each tier is a closest->widest lead
|
||||||
limit: int = 7, window_days: int = 3) -> list[dict]:
|
# preference, not a hard filter; 'world' is the implicit final tier. State + region
|
||||||
"""Local-first landing highlights. Leads with high/medium-confidence local good news,
|
# are confidence-gated (high/medium) so a shaky location is never promoted as local.
|
||||||
then blends out to your country and the world so the set is always full (never the
|
_STATE_SQL = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
||||||
sad thin-local look), and prefers already-summarized stories so the calm read stays
|
"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code = ?))")
|
||||||
rich. Brief-shaped rows (incl. summary) tagged with a section, best-first within tier.
|
_COUNTRY_SQL = "(EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?))"
|
||||||
|
SCOPES = ("nearby", "region", "country", "world")
|
||||||
|
|
||||||
|
|
||||||
|
def _region_sql(n: int) -> str:
|
||||||
|
placeholders = ",".join("?" * n)
|
||||||
|
return ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
||||||
|
f"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code IN ({placeholders})))")
|
||||||
|
|
||||||
|
|
||||||
|
def home_tiers(home_country: str, home_state: str | None, scope: str) -> list[tuple]:
|
||||||
|
"""Ordered [(section_key, predicate_sql, params)] closest->widest for a home + scope.
|
||||||
|
Evaluated first-match (CASE WHEN / composed in order), so tiers needn't be SQL-exclusive.
|
||||||
|
'world' is implicit (everything not matched). 'region'/'nearby' need a US state; otherwise
|
||||||
|
they gracefully fall back to country (country-only / non-US homes collapse to Country/World).
|
||||||
"""
|
"""
|
||||||
if home_state:
|
from .geo import region_states
|
||||||
near = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
rs = region_states(home_state) if home_state else []
|
||||||
"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code = ?))")
|
tiers: list[tuple] = []
|
||||||
country = "EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?)"
|
if scope == "nearby" and home_state:
|
||||||
section_case = f"CASE WHEN {near} THEN 0 WHEN {country} THEN 1 ELSE 2 END"
|
tiers.append(("state", _STATE_SQL, [home_country, home_state]))
|
||||||
section_params = [home_country, home_state, home_country]
|
if rs:
|
||||||
else:
|
tiers.append(("region", _region_sql(len(rs)), [home_country, *rs]))
|
||||||
near = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
|
tiers.append(("country", _COUNTRY_SQL, [home_country]))
|
||||||
"WHERE p.article_id = a.id AND p.country_code = ?))")
|
elif scope == "region" and home_state and rs:
|
||||||
section_case = f"CASE WHEN {near} THEN 0 ELSE 2 END" # no "country" tier without a state
|
tiers.append(("region", _region_sql(len(rs)), [home_country, *rs])) # includes the state
|
||||||
section_params = [home_country]
|
tiers.append(("country", _COUNTRY_SQL, [home_country]))
|
||||||
|
else: # country scope, country-only / non-US home, or any fallback
|
||||||
|
tiers.append(("country", _COUNTRY_SQL, [home_country]))
|
||||||
|
return tiers
|
||||||
|
|
||||||
|
|
||||||
|
def home_brief(conn: sqlite3.Connection, home_country: str, home_state: str | None = None,
|
||||||
|
scope: str = "nearby", limit: int = 7, window_days: int = 3) -> list[dict]:
|
||||||
|
"""Scope-aware local-first landing highlights. Leads with the reader's chosen radius
|
||||||
|
(state / region / country) then blends outward so the set is always full — "closest
|
||||||
|
first", never three stale local stories. Prefers already-summarized stories so the
|
||||||
|
calm read stays rich. Brief-shaped rows tagged with a concrete section key.
|
||||||
|
"""
|
||||||
|
tiers = home_tiers(home_country, home_state, scope)
|
||||||
|
whens, params = [], []
|
||||||
|
for i, (_key, pred, ps) in enumerate(tiers):
|
||||||
|
whens.append(f"WHEN {pred} THEN {i}")
|
||||||
|
params += ps
|
||||||
|
world_rank = len(tiers)
|
||||||
|
section_case = ("CASE " + " ".join(whens) + f" ELSE {world_rank} END") if whens else "0"
|
||||||
|
section_keys = [k for k, _, _ in tiers] + ["world"]
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {_ARTICLE_COLUMNS},
|
SELECT {_ARTICLE_COLUMNS},
|
||||||
@@ -294,12 +328,13 @@ def home_brief(conn: sqlite3.Connection, home_country: str, home_state: str | No
|
|||||||
COALESCE(a.published_at, a.discovered_at) DESC
|
COALESCE(a.published_at, a.discovered_at) DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
section_params + [f"-{window_days} days", limit],
|
params + [f"-{window_days} days", limit],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
d["__section"] = {0: "near", 1: "country", 2: "world"}.get(d.pop("section_rank", 2), "world")
|
rank = d.pop("section_rank", world_rank)
|
||||||
|
d["__section"] = section_keys[rank] if 0 <= rank < len(section_keys) else "world"
|
||||||
out.append(d)
|
out.append(d)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
+16
-8
@@ -82,20 +82,28 @@ def test_country_only_home_gates_near_on_confidence(app_db):
|
|||||||
|
|
||||||
|
|
||||||
def test_home_brief_leads_with_local(app_db):
|
def test_home_brief_leads_with_local(app_db):
|
||||||
# The landing's /api/brief?home=US-NY leads with high-confidence NY good news,
|
# /api/brief?home=US-NY defaults to the 'nearby' scope: leads with the 'state' tier
|
||||||
# tags items by section, and titles it "Close to home". The low-conf NY story (#5)
|
# (high-confidence NY), titles it "Close to home", and never elevates the low-conf
|
||||||
# is not elevated as local.
|
# NY story (#5). CA stories (a different census region) fall to 'country'.
|
||||||
r = TestClient(app_db).get("/api/brief?home=US-NY&limit=10").json()
|
r = TestClient(app_db).get("/api/brief?home=US-NY&limit=10").json()
|
||||||
assert r["title"] == "Close to home"
|
assert r["title"] == "Close to home"
|
||||||
near_ids = {it["id"] for it in r["items"] if it["section"] == "near"}
|
state_ids = {it["id"] for it in r["items"] if it["section"] == "state"}
|
||||||
assert near_ids == {1, 2, 3, 4} # the high-conf NY stories
|
assert state_ids == {1, 2, 3, 4} # high-conf NY only
|
||||||
# near items all appear before any world item (local leads)
|
a5 = next(it for it in r["items"] if it["id"] == 5)
|
||||||
|
assert a5["section"] == "country" # low-conf NY -> not 'state'
|
||||||
secs = [it["section"] for it in r["items"]]
|
secs = [it["section"] for it in r["items"]]
|
||||||
if "near" in secs and "world" in secs:
|
if "state" in secs and "world" in secs: # local leads, world trails
|
||||||
assert max(i for i, s in enumerate(secs) if s == "near") < \
|
assert max(i for i, s in enumerate(secs) if s == "state") < \
|
||||||
min(i for i, s in enumerate(secs) if s == "world")
|
min(i for i, s in enumerate(secs) if s == "world")
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_brief_scope_country_has_no_state_tier(app_db):
|
||||||
|
# The 'country' scope drops the local lead: all US stories sit in 'country', none 'state'.
|
||||||
|
r = TestClient(app_db).get("/api/brief?home=US-NY&scope=country&limit=10").json()
|
||||||
|
secs = {it["section"] for it in r["items"]}
|
||||||
|
assert "state" not in secs and "country" in secs
|
||||||
|
|
||||||
|
|
||||||
def test_no_home_is_unchanged_and_unsectioned(app_db):
|
def test_no_home_is_unchanged_and_unsectioned(app_db):
|
||||||
r = TestClient(app_db).get("/api/feed?limit=50").json()
|
r = TestClient(app_db).get("/api/feed?limit=50").json()
|
||||||
assert all(it["section"] is None for it in r["items"])
|
assert all(it["section"] is None for it in r["items"])
|
||||||
|
|||||||
Reference in New Issue
Block a user