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:
+21
-3
@@ -34,7 +34,7 @@ from .hero import safe_to_lead
|
||||
from .llm import LocalModelClient
|
||||
from .moods import MOODS, mood_filter
|
||||
from .paywall import is_paywalled
|
||||
from .taxonomy import FLAVORS, TOPICS
|
||||
from .taxonomy import FAMILIES, FLAVORS, TOPICS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
||||
@@ -126,9 +126,11 @@ class Article(BaseModel):
|
||||
model_name: str | None = None
|
||||
rank: int | None = None # position within a brief, when applicable
|
||||
paywalled: bool = False
|
||||
tags: list[str] = []
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: dict) -> "Article":
|
||||
raw_tags = row.get("tags")
|
||||
return cls(
|
||||
id=row["id"],
|
||||
title=row["title"],
|
||||
@@ -146,6 +148,7 @@ class Article(BaseModel):
|
||||
model_name=row.get("model_name"),
|
||||
rank=row.get("rank"),
|
||||
paywalled=is_paywalled(row.get("canonical_url")),
|
||||
tags=[t for t in (raw_tags.split(",") if raw_tags else []) if t],
|
||||
)
|
||||
|
||||
|
||||
@@ -240,6 +243,20 @@ def create_app() -> FastAPI:
|
||||
# client merges with the user's own Calm Filters.
|
||||
return MOODS
|
||||
|
||||
@app.get("/api/families")
|
||||
def families() -> list[dict]:
|
||||
# Grouping vocabulary organised into calm families for the Explore UI.
|
||||
with get_conn() as conn:
|
||||
counts = queries.tag_counts(conn)
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"description": d["description"],
|
||||
"tags": [{"key": t, "count": counts.get(t, 0)} for t in d["tags"]],
|
||||
}
|
||||
for name, d in FAMILIES.items()
|
||||
]
|
||||
|
||||
@app.get("/api/category-counts", response_model=list[CategoryCount])
|
||||
def category_counts(accepted_only: bool = True, prefs: str | None = Query(None)) -> list[CategoryCount]:
|
||||
fp = prefs_from_json(prefs)
|
||||
@@ -267,6 +284,7 @@ def create_app() -> FastAPI:
|
||||
offset: int = Query(0, ge=0),
|
||||
prefs: str | None = Query(None),
|
||||
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
|
||||
tag: str | None = Query(None, description="grouping tag to browse"),
|
||||
) -> FeedResponse:
|
||||
if topic and topic.lower() not in TOPICS:
|
||||
raise HTTPException(400, f"unknown topic: {topic}")
|
||||
@@ -285,14 +303,14 @@ def create_app() -> FastAPI:
|
||||
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
|
||||
raw = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
||||
limit=fetch_n, offset=0, **kw,
|
||||
limit=fetch_n, offset=0, tag=tag, **kw,
|
||||
)
|
||||
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
|
||||
rows = kept[offset : offset + limit]
|
||||
else:
|
||||
rows = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
||||
limit=limit, offset=offset, **kw,
|
||||
limit=limit, offset=offset, tag=tag, **kw,
|
||||
)
|
||||
# Keep the top of a browse view readable: stable-sort paywalled items
|
||||
# below readable ones (composite order preserved within each group).
|
||||
|
||||
Reference in New Issue
Block a user