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:
+23
-3
@@ -9,11 +9,15 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .taxonomy import (
|
||||
ALLOWED_TAGS,
|
||||
FLAVORS,
|
||||
MAX_TAGS,
|
||||
TOPICS,
|
||||
coerce_flavor,
|
||||
coerce_tags,
|
||||
coerce_topic,
|
||||
flavors_prompt_block,
|
||||
tags_prompt_block,
|
||||
topics_prompt_block,
|
||||
)
|
||||
|
||||
@@ -42,6 +46,7 @@ CLASSIFICATION_SCHEMA = {
|
||||
"accepted",
|
||||
"topic",
|
||||
"flavor",
|
||||
"tags",
|
||||
"reason_code",
|
||||
"reason_text",
|
||||
],
|
||||
@@ -56,6 +61,7 @@ CLASSIFICATION_SCHEMA = {
|
||||
"accepted": {"type": "boolean"},
|
||||
"topic": {"type": "string", "enum": list(TOPICS)},
|
||||
"flavor": {"type": "string", "enum": list(FLAVORS)},
|
||||
"tags": {"type": "array", "items": {"type": "string", "enum": list(ALLOWED_TAGS)}, "maxItems": MAX_TAGS},
|
||||
"reason_code": {"type": "string"},
|
||||
"reason_text": {"type": "string"},
|
||||
},
|
||||
@@ -76,14 +82,20 @@ Judge emotional aftertaste, not simple positivity. Accept stories that leave a r
|
||||
|
||||
Reject stories centered on fear, outrage, partisan conflict, crime, tragedy, disaster repetition, celebrity drama, market panic, or corporate PR without clear public benefit.
|
||||
|
||||
Also assign one topic and one flavor, choosing the single best fit.
|
||||
Also assign one primary topic and one flavor (the single best fit), plus 1-4 grouping tags.
|
||||
|
||||
Topic (what the story is about):
|
||||
Primary topic (what the story is mainly about):
|
||||
{topics}
|
||||
|
||||
Flavor (why it belongs in a calm, uplifting digest):
|
||||
{flavors}
|
||||
|
||||
Grouping tags — choose ONLY from this controlled vocabulary:
|
||||
{tags}
|
||||
|
||||
Tag discipline: assign 1-4 tags; prefer fewer, stronger ones; never tag by weak
|
||||
association; pick tags a reader would reasonably use to find this story later.
|
||||
|
||||
Return only JSON with this exact shape:
|
||||
{{
|
||||
"constructive_score": 0,
|
||||
@@ -96,10 +108,11 @@ Return only JSON with this exact shape:
|
||||
"accepted": false,
|
||||
"topic": "one_of_the_allowed_topics",
|
||||
"flavor": "one_of_the_allowed_flavors",
|
||||
"tags": ["one_to_four_allowed_tags"],
|
||||
"reason_code": "short_snake_case",
|
||||
"reason_text": "one concise sentence"
|
||||
}}
|
||||
""".format(topics=topics_prompt_block(), flavors=flavors_prompt_block())
|
||||
""".format(topics=topics_prompt_block(), flavors=flavors_prompt_block(), tags=tags_prompt_block())
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -285,6 +298,7 @@ def normalize_scores(data: dict, model_name: str) -> dict:
|
||||
"accepted": 1 if bool(data.get("accepted")) else 0,
|
||||
"topic": coerce_topic(data.get("topic")),
|
||||
"flavor": coerce_flavor(data.get("flavor")),
|
||||
"tags": coerce_tags(data.get("tags")),
|
||||
"reason_code": str(data.get("reason_code") or "model_no_reason")[:120],
|
||||
"reason_text": str(data.get("reason_text") or "")[:1000],
|
||||
"model_name": model_name,
|
||||
@@ -333,6 +347,12 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
|
||||
scores["model_name"],
|
||||
),
|
||||
)
|
||||
# Replace this article's grouping tags (controlled vocabulary, 0-4).
|
||||
conn.execute("DELETE FROM article_tags WHERE article_id = ?", (article_id,))
|
||||
for tag in scores.get("tags") or []:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO article_tags (article_id, tag) VALUES (?, ?)", (article_id, tag)
|
||||
)
|
||||
|
||||
|
||||
def _classification_candidates(
|
||||
|
||||
Reference in New Issue
Block a user