Add topic/flavor categorization and category browsing

- New taxonomy module: single source of truth for 6 topics x 5 flavors,
  shared by the LLM response schema (enum-constrained) and validation.
- Classifier now assigns one topic + one flavor per article; json_schema
  enums force valid values, with coercion as a safety net.
- article_scores gains topic/flavor columns via an idempotent migration.
- New 'list-category' command to browse by topic and/or flavor, ranked by
  composite score.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-30 11:21:53 +00:00
parent f4842ed100
commit 38057d0354
5 changed files with 165 additions and 6 deletions
+34 -5
View File
@@ -7,6 +7,15 @@ import urllib.error
import urllib.request
from dataclasses import dataclass
from .taxonomy import (
FLAVORS,
TOPICS,
coerce_flavor,
coerce_topic,
flavors_prompt_block,
topics_prompt_block,
)
DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1"
DEFAULT_MODEL = "gpt-oss"
@@ -29,6 +38,8 @@ CLASSIFICATION_SCHEMA = {
"novelty_score",
"pr_risk_score",
"accepted",
"topic",
"flavor",
"reason_code",
"reason_text",
],
@@ -41,6 +52,8 @@ CLASSIFICATION_SCHEMA = {
"novelty_score": _SCORE_FIELD,
"pr_risk_score": _SCORE_FIELD,
"accepted": {"type": "boolean"},
"topic": {"type": "string", "enum": list(TOPICS)},
"flavor": {"type": "string", "enum": list(FLAVORS)},
"reason_code": {"type": "string"},
"reason_text": {"type": "string"},
},
@@ -61,8 +74,16 @@ 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.
Topic (what the story is about):
{topics}
Flavor (why it belongs in a calm, uplifting digest):
{flavors}
Return only JSON with this exact shape:
{
{{
"constructive_score": 0,
"cortisol_score": 0,
"ragebait_score": 0,
@@ -71,10 +92,12 @@ Return only JSON with this exact shape:
"novelty_score": 0,
"pr_risk_score": 0,
"accepted": false,
"topic": "one_of_the_allowed_topics",
"flavor": "one_of_the_allowed_flavors",
"reason_code": "short_snake_case",
"reason_text": "one concise sentence"
}
"""
}}
""".format(topics=topics_prompt_block(), flavors=flavors_prompt_block())
@dataclass
@@ -218,6 +241,8 @@ def normalize_scores(data: dict, model_name: str) -> dict:
"novelty_score": _bounded_int(data.get("novelty_score")),
"pr_risk_score": _bounded_int(data.get("pr_risk_score")),
"accepted": 1 if bool(data.get("accepted")) else 0,
"topic": coerce_topic(data.get("topic")),
"flavor": coerce_flavor(data.get("flavor")),
"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,
@@ -230,9 +255,9 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
INSERT INTO article_scores (
article_id, constructive_score, cortisol_score, ragebait_score,
agency_score, human_benefit_score, novelty_score, pr_risk_score,
accepted, reason_code, reason_text, model_name, scored_at
accepted, topic, flavor, reason_code, reason_text, model_name, scored_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(article_id) DO UPDATE SET
constructive_score = excluded.constructive_score,
cortisol_score = excluded.cortisol_score,
@@ -242,6 +267,8 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
novelty_score = excluded.novelty_score,
pr_risk_score = excluded.pr_risk_score,
accepted = excluded.accepted,
topic = excluded.topic,
flavor = excluded.flavor,
reason_code = excluded.reason_code,
reason_text = excluded.reason_text,
model_name = excluded.model_name,
@@ -257,6 +284,8 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
scores["novelty_score"],
scores["pr_risk_score"],
scores["accepted"],
scores["topic"],
scores["flavor"],
scores["reason_code"],
scores["reason_text"],
scores["model_name"],