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:
@@ -36,6 +36,7 @@ _ARTICLE_COLUMNS = f"""
|
||||
s.reason_code,
|
||||
s.reason_text,
|
||||
s.model_name,
|
||||
(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags,
|
||||
{RANK_SCORE_SQL} AS rank_score
|
||||
"""
|
||||
|
||||
@@ -53,6 +54,7 @@ def feed(
|
||||
mute_flavors: list[str] | None = None,
|
||||
max_cortisol: int | None = None,
|
||||
max_ragebait: int | None = None,
|
||||
tag: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return ranked articles with categorical filters applied in SQL.
|
||||
|
||||
@@ -94,6 +96,9 @@ def feed(
|
||||
if max_ragebait is not None:
|
||||
clauses.append("COALESCE(s.ragebait_score, 0) <= ?")
|
||||
params.append(max_ragebait)
|
||||
if tag:
|
||||
clauses.append("EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id AND at.tag = ?)")
|
||||
params.append(tag.lower())
|
||||
|
||||
where = "WHERE " + " AND ".join(clauses)
|
||||
params.extend([limit, offset])
|
||||
@@ -148,6 +153,22 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
||||
}
|
||||
|
||||
|
||||
def tag_counts(conn: sqlite3.Connection, accepted_only: bool = True) -> dict:
|
||||
"""How many shown (accepted, non-duplicate) articles carry each grouping tag."""
|
||||
where = "WHERE a.duplicate_of IS NULL" + (" AND s.accepted = 1" if accepted_only else "")
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT t.tag, COUNT(*) AS count
|
||||
FROM article_tags t
|
||||
JOIN articles a ON a.id = t.article_id
|
||||
JOIN article_scores s ON s.article_id = a.id
|
||||
{where}
|
||||
GROUP BY t.tag
|
||||
"""
|
||||
).fetchall()
|
||||
return {r["tag"]: r["count"] for r in rows}
|
||||
|
||||
|
||||
def category_counts(conn: sqlite3.Connection, accepted_only: bool = True) -> list[dict]:
|
||||
"""Return per topic/flavor article counts for building browse UIs.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user