722bcf6317
Readers can now choose which quick-access lanes sit above the feed; "Today" stays pinned. The pool (goodnews/lanes.py, served at /api/lanes) is one source of truth over three lane kinds the feed already renders: moods, primary topics, and high-volume Discovery tags. Selection lives in the existing prefs blob (localStorage + /api/prefs sync); the filter parser ignores the new `lanes` field, so it rides along harmlessly. Default = today's moods, unchanged. Food/Space stay grouping tags rather than primary topics (per review): `space` already existed; added `food` to the Mind & Craft family so the classifier assigns it, and seeded the Food lane by re-tagging the two food sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
102 lines
3.9 KiB
Python
102 lines
3.9 KiB
Python
"""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
|
||
|
||
|
||
# Primary topic — exactly one per article. Used for ranking, brief balance, and
|
||
# source reports (the "machine organization" axis).
|
||
TOPICS: dict[str, str] = {
|
||
"science": "research, discoveries, space, physics",
|
||
"technology": "computing, AI, engineering, gadgets, digital tools",
|
||
"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",
|
||
"learning": "education, personal growth, practical knowledge, curiosity",
|
||
}
|
||
|
||
# Groupings — 1–4 per article, the "human wandering" axis. A controlled
|
||
# vocabulary (never free-form) organised into calm families for the Explore UI.
|
||
# Families live in code, not the DB. Tag slugs are lowercase, hyphenated.
|
||
FAMILIES: dict[str, dict] = {
|
||
"Discovery & Wonder": {
|
||
"description": "Awe, science, and the natural world.",
|
||
"tags": ["science", "space", "animals", "nature", "archaeology", "technology", "curiosity"],
|
||
},
|
||
"People & Kindness": {
|
||
"description": "Community, generosity, and human warmth.",
|
||
"tags": ["community", "helping", "culture", "generosity", "resilience", "local-wins"],
|
||
},
|
||
"Solutions & Progress": {
|
||
"description": "Problems being solved.",
|
||
"tags": ["environment", "climate-solutions", "public-health", "cities", "clean-energy", "innovation"],
|
||
},
|
||
"Mind & Craft": {
|
||
"description": "Ideas, learning, and making.",
|
||
"tags": ["learning", "ideas", "arts", "books", "creativity", "perspective", "work-life", "food"],
|
||
},
|
||
}
|
||
|
||
# Flat allowed-tag set (union of all families), for enum + validation.
|
||
ALLOWED_TAGS: tuple[str, ...] = tuple(dict.fromkeys(t for f in FAMILIES.values() for t in f["tags"]))
|
||
MAX_TAGS = 4
|
||
|
||
# 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 coerce_tags(value: object, max_tags: int = MAX_TAGS) -> list[str]:
|
||
"""Validate a model-supplied tag list against the controlled vocabulary."""
|
||
if not isinstance(value, list):
|
||
return []
|
||
out: list[str] = []
|
||
for item in value:
|
||
tag = str(item).strip().lower()
|
||
if tag in ALLOWED_TAGS and tag not in out:
|
||
out.append(tag)
|
||
if len(out) >= max_tags:
|
||
break
|
||
return out
|
||
|
||
|
||
def tags_prompt_block() -> str:
|
||
return "\n".join(f"- {family}: {', '.join(d['tags'])}" for family, d in FAMILIES.items())
|
||
|
||
|
||
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)
|