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
+61 -1
View File
@@ -38,6 +38,12 @@ def main() -> None:
source_parser = subparsers.add_parser("list-sources", help="Show configured sources")
source_parser.add_argument("--active-only", action="store_true")
cat_parser = subparsers.add_parser("list-category", help="Browse articles by topic and/or flavor")
cat_parser.add_argument("--topic", help="Filter by topic, e.g. science, environment, animals")
cat_parser.add_argument("--flavor", help="Filter by flavor, e.g. breakthrough, discovery, feelgood")
cat_parser.add_argument("--limit", type=int, default=20)
cat_parser.add_argument("--all", action="store_true", help="Include not-accepted articles")
subparsers.add_parser("source-report", help="Show source-level ingestion and scoring stats")
runs_parser = subparsers.add_parser("list-runs", help="Show recent ingest runs")
@@ -90,6 +96,8 @@ def main() -> None:
list_recent(conn, limit=args.limit, accepted_only=args.accepted_only)
elif args.command == "list-sources":
list_sources(conn, active_only=args.active_only)
elif args.command == "list-category":
list_category(conn, topic=args.topic, flavor=args.flavor, limit=args.limit, accepted_only=not args.all)
elif args.command == "source-report":
source_report(conn)
elif args.command == "list-runs":
@@ -109,7 +117,10 @@ def main() -> None:
)
for article_id, scores in results:
accepted = "yes" if scores["accepted"] else "no"
print(f"[{article_id}] accepted={accepted} reason={scores['reason_code']}")
print(
f"[{article_id}] accepted={accepted} {scores['topic']}/{scores['flavor']} "
f"reason={scores['reason_code']}"
)
print(f" {scores['reason_text']}")
if args.dry_run:
print("Dry run only; database was not updated.")
@@ -179,6 +190,55 @@ def list_recent(conn: sqlite3.Connection, limit: int, accepted_only: bool) -> No
print(f" {row['canonical_url']}")
def list_category(
conn: sqlite3.Connection,
topic: str | None,
flavor: str | None,
limit: int,
accepted_only: bool,
) -> None:
clauses = []
params: list = []
if accepted_only:
clauses.append("s.accepted = 1")
if topic:
clauses.append("s.topic = ?")
params.append(topic.lower())
if flavor:
clauses.append("s.flavor = ?")
params.append(flavor.lower())
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(limit)
rows = conn.execute(
f"""
SELECT
a.id, a.title, a.canonical_url, a.published_at,
src.name AS source_name,
s.topic, s.flavor, s.accepted,
s.constructive_score, s.cortisol_score, s.reason_code,
(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score
- s.cortisol_score - s.ragebait_score - s.pr_risk_score) AS rank_score
FROM articles a
JOIN sources src ON src.id = a.source_id
JOIN article_scores s ON s.article_id = a.id
{where}
ORDER BY rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC
LIMIT ?
""",
params,
).fetchall()
label = " / ".join(filter(None, [topic, flavor])) or "all categories"
print(f"{label} ({len(rows)} shown)")
for row in rows:
accepted = "" if row["accepted"] else " [not accepted]"
print(f"[{row['id']}] {row['topic']}/{row['flavor']} | {row['source_name']}{accepted}")
print(f" {row['title']}")
print(f" score={row['rank_score']} reason={row['reason_code']}")
print(f" {row['canonical_url']}")
def llm_client_from_args(args: argparse.Namespace) -> LocalModelClient:
client = LocalModelClient.from_env()
if getattr(args, "base_url", None):