Geo Stage 4 (server): home-aware feed sectioning (Near you / country / world)
Completes the server side of "Closer to Home". /api/feed gains a `home` param
('US' or 'US-NY'); when set the response is private (like prefs) and sectioned:
- Near you (+ Elsewhere in your country when a state is set) is a ONE-TIME lead
block on page 0; the world is the paginated body. next_offset tells the client
where to continue, so the lead block never skews world paging.
- Thin tiers fold down (MIN_TIER=3) so a header is never shown empty (lead, don't trap).
- State match counts only on high/medium geo confidence; the "country" tier excludes
exactly what went to "near", so a low-confidence home-state story still surfaces
(it doesn't vanish between tiers — caught + tested).
- Items carry a `section` tag; paywalled sort is now within-section. No home => exact
prior behavior (section null, default/edge-cached feed unchanged), Brief untouched.
364 tests green. Frontend next: Home picker + sectioned feed rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""Closer to Home: /api/feed?home=... sections the feed into near / country / world,
|
||||
state-match only on high/medium confidence, sparse tiers fold down, Brief untouched."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from goodnews.db import connect, init_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_db(tmp_path, monkeypatch):
|
||||
db = tmp_path / "t.sqlite3"
|
||||
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
|
||||
import importlib
|
||||
import goodnews.api as api
|
||||
importlib.reload(api)
|
||||
c = connect(str(db)); init_db(c)
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,trust_score,content_visible) VALUES (1,'S','http://s/f',5,1)")
|
||||
|
||||
def art(aid, *, breadth, conf, country=None, state=None):
|
||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,published_at) "
|
||||
"VALUES (?,1,?,?,?,?)", (aid, f"https://x/{aid}", f"Story {aid}", f"h{aid}", "2026-06-18T08:00:00"))
|
||||
c.execute("INSERT INTO article_scores (article_id,accepted,novelty_score,constructive_score) "
|
||||
"VALUES (?,1,5,5)", (aid,))
|
||||
c.execute("INSERT INTO article_geo (article_id,breadth,confidence,geo_version) VALUES (?,?,?, 'geo-v1')",
|
||||
(aid, breadth, conf))
|
||||
if country:
|
||||
c.execute("INSERT INTO article_places (article_id,country_code,state_code,ord) VALUES (?,?,?,0)",
|
||||
(aid, country, state))
|
||||
|
||||
# 4 NY high-conf (near), 1 NY LOW-conf (must NOT be near), 3 US/CA (country), 4 world
|
||||
for i in range(1, 5):
|
||||
art(i, breadth="locality", conf="high", country="US", state="NY")
|
||||
art(5, breadth="locality", conf="low", country="US", state="NY")
|
||||
for i in range(6, 9):
|
||||
art(i, breadth="regional", conf="high", country="US", state="CA")
|
||||
for i in range(9, 13):
|
||||
art(i, breadth="global", conf="high") # placeless -> world
|
||||
c.commit(); c.close()
|
||||
return api.create_app()
|
||||
|
||||
|
||||
def _sections(items):
|
||||
return [it["section"] for it in items]
|
||||
|
||||
|
||||
def test_home_state_sections_near_country_world(app_db):
|
||||
r = TestClient(app_db).get("/api/feed?home=US-NY&limit=50").json()
|
||||
items = r["items"]
|
||||
secs = _sections(items)
|
||||
# order: all near, then all country, then all world (no interleaving)
|
||||
assert secs == sorted(secs, key=["near", "country", "world"].index)
|
||||
near = [it for it in items if it["section"] == "near"]
|
||||
assert len(near) == 4 and all(it["geo_confidence"] == "high" for it in near)
|
||||
# the LOW-confidence NY story is NOT "near" — state match needs high/medium
|
||||
a5 = next(it for it in items if it["id"] == 5)
|
||||
assert a5["section"] == "country"
|
||||
# world holds the placeless/global ones
|
||||
assert {it["id"] for it in items if it["section"] == "world"} == {9, 10, 11, 12}
|
||||
|
||||
|
||||
def test_sparse_near_folds_into_country(app_db):
|
||||
# home in a state with no high-conf local stories -> "near" is empty, no near header;
|
||||
# its (none) items fold away and we still get country + world, never an empty tier.
|
||||
r = TestClient(app_db).get("/api/feed?home=US-TX&limit=50").json()
|
||||
assert "near" not in _sections(r["items"]) # nothing local to TX -> no near section
|
||||
assert "country" in _sections(r["items"]) # US stories still surface as your country
|
||||
|
||||
|
||||
def test_country_only_home_has_no_country_tier(app_db):
|
||||
r = TestClient(app_db).get("/api/feed?home=US&limit=50").json()
|
||||
secs = set(_sections(r["items"]))
|
||||
assert "country" not in secs # no state -> near IS the whole country
|
||||
assert secs <= {"near", "world"}
|
||||
near_ids = {it["id"] for it in r["items"] if it["section"] == "near"}
|
||||
assert near_ids == {1, 2, 3, 4, 5, 6, 7, 8} # all US (incl. the low-conf one, country match)
|
||||
|
||||
|
||||
def test_no_home_is_unchanged_and_unsectioned(app_db):
|
||||
r = TestClient(app_db).get("/api/feed?limit=50").json()
|
||||
assert all(it["section"] is None for it in r["items"])
|
||||
assert r["next_offset"] is None or isinstance(r["next_offset"], int)
|
||||
Reference in New Issue
Block a user