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
+54
View File
@@ -0,0 +1,54 @@
"""Single source of truth for article topic/flavor categories.
Both the LLM response schema (enum constraints) and the post-hoc validation in
normalize_scores import from here, so the allowed values can never drift apart.
Adjusting a category here + re-running `classify` is all it takes to reshape the
browsable feeds.
"""
from __future__ import annotations
# Topical axis: what the story is primarily about.
TOPICS: dict[str, str] = {
"science": "research, discoveries, space, physics, technology",
"environment": "conservation, climate solutions, ecosystems, clean energy",
"health": "medicine, wellbeing, mental health, public health",
"community": "local action, humanitarian work, social progress, kindness, fair work",
"culture": "arts, history, heritage, sport, human-interest",
"animals": "wildlife, nature discoveries, charming animal stories",
}
# Tonal axis: why the story is worth surfacing in a calm, uplifting digest.
FLAVORS: dict[str, str] = {
"breakthrough": "a significant advance or innovation with clear public benefit",
"discovery": "newly found or learned; calm and fascinating, low on agency",
"solution": "people actively repairing, restoring, or solving a problem",
"feelgood": "a heartwarming human, community, or kindness story",
"perspective": "useful advice, insight, or framing the reader can apply",
}
DEFAULT_TOPIC = "science"
DEFAULT_FLAVOR = "discovery"
def coerce_topic(value: object) -> str:
text = str(value or "").strip().lower()
return text if text in TOPICS else DEFAULT_TOPIC
def coerce_flavor(value: object) -> str:
text = str(value or "").strip().lower()
return text if text in FLAVORS else DEFAULT_FLAVOR
def _bullet_list(mapping: dict[str, str]) -> str:
return "\n".join(f"- {key}: {desc}" for key, desc in mapping.items())
def topics_prompt_block() -> str:
return _bullet_list(TOPICS)
def flavors_prompt_block() -> str:
return _bullet_list(FLAVORS)