Phase B1: multi-tag groupings model (backend)
Three-layer organization: primary topic (one per article, for ranking and brief balance) + grouping tags (1-4 per article from a controlled vocabulary, the organic "wandering" axis) + tonal flavor. - taxonomy: add technology + learning topics; 4 calm tag families (Discovery & Wonder, People & Kindness, Solutions & Progress, Mind & Craft) defined in code, not the DB; ALLOWED_TAGS union + coerce_tags validation. - db: article_tags(article_id, tag) join table + tag index. - llm: tags added to the classifier json_schema (enum-constrained, maxItems 4) and system prompt; normalize_scores coerces tags; upsert_article_score replaces a row's tags atomically on every (re)classification. - queries: feed gains a tag filter and exposes tags via group_concat; tag_counts. - api: Article.tags, feed tag param, and /api/families with per-tag counts. - tests: coerce/normalize/upsert/tag-filter/reclassify-replace/tag_counts + /api/families. 99 passing. Corpus reclassify (re-tag + new primary topics) runs separately against the local LLM. Frontend (B2) pairs with this; the live site is unchanged until then. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,3 +80,10 @@ def test_feed_excludes_dismissed(client):
|
||||
r = client.get("/api/feed", params={"exclude": "1"})
|
||||
ids = [i["id"] for i in r.json()["items"]]
|
||||
assert 1 not in ids
|
||||
|
||||
|
||||
def test_families_endpoint(client):
|
||||
fams = client.get("/api/families").json()
|
||||
names = [f["name"] for f in fams]
|
||||
assert "Discovery & Wonder" in names
|
||||
assert all("tags" in f and isinstance(f["tags"], list) for f in fams)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from goodnews.taxonomy import coerce_tags
|
||||
from goodnews.db import connect, init_db
|
||||
from goodnews.llm import normalize_scores, upsert_article_score
|
||||
from goodnews import queries
|
||||
|
||||
|
||||
def test_coerce_tags_validates_dedupes_caps():
|
||||
assert coerce_tags(["science", "space", "bogus", "science"]) == ["science", "space"]
|
||||
assert coerce_tags(["science", "space", "animals", "nature", "archaeology"]) == \
|
||||
["science", "space", "animals", "nature"] # capped at 4
|
||||
assert coerce_tags("not-a-list") == []
|
||||
assert coerce_tags(None) == []
|
||||
|
||||
|
||||
def test_normalize_includes_valid_tags_only():
|
||||
s = normalize_scores({"topic": "technology", "flavor": "discovery", "tags": ["space", "nope"]}, "m")
|
||||
assert s["topic"] == "technology" # new primary topic accepted
|
||||
assert s["tags"] == ["space"]
|
||||
|
||||
|
||||
def _db():
|
||||
c = connect(":memory:"); init_db(c)
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)")
|
||||
for aid in (1, 2):
|
||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (?,1,?,?,?)",
|
||||
(aid, f"http://s/{aid}", f"t{aid}", f"h{aid}"))
|
||||
c.commit()
|
||||
return c
|
||||
|
||||
|
||||
def _score(tags, topic="science"):
|
||||
return normalize_scores({"topic": topic, "flavor": "discovery", "accepted": True,
|
||||
"constructive_score": 7, "agency_score": 2, "human_benefit_score": 2,
|
||||
"tags": tags}, "m")
|
||||
|
||||
|
||||
def test_upsert_writes_tags_and_feed_filters_by_tag():
|
||||
c = _db()
|
||||
upsert_article_score(c, 1, _score(["space", "animals"]))
|
||||
upsert_article_score(c, 2, _score(["community"], topic="community"))
|
||||
c.commit()
|
||||
assert [r["id"] for r in queries.feed(c, tag="space", limit=50)] == [1]
|
||||
assert [r["id"] for r in queries.feed(c, tag="community", limit=50)] == [2]
|
||||
row1 = next(r for r in queries.feed(c, limit=50) if r["id"] == 1)
|
||||
assert set(row1["tags"].split(",")) == {"space", "animals"}
|
||||
|
||||
|
||||
def test_reclassify_replaces_old_tags():
|
||||
c = _db()
|
||||
upsert_article_score(c, 1, _score(["space", "animals"]))
|
||||
c.commit()
|
||||
upsert_article_score(c, 1, _score(["science"])) # re-tag
|
||||
c.commit()
|
||||
assert [r["id"] for r in queries.feed(c, tag="animals", limit=50)] == [] # old tag gone
|
||||
assert [r["id"] for r in queries.feed(c, tag="science", limit=50)] == [1]
|
||||
|
||||
|
||||
def test_tag_counts():
|
||||
c = _db()
|
||||
upsert_article_score(c, 1, _score(["space", "science"]))
|
||||
upsert_article_score(c, 2, _score(["science"]))
|
||||
c.commit()
|
||||
counts = queries.tag_counts(c)
|
||||
assert counts["science"] == 2 and counts["space"] == 1
|
||||
Reference in New Issue
Block a user