Build the SvelteKit frontend: calm home with mood modes
- New frontend/ SvelteKit static SPA (Svelte 5), served by FastAPI from frontend/build (falls back to the legacy page if unbuilt). - Calm design system: cream/sage palette, serif headlines, generous space, no urgency colors, gentle motion (respects prefers-reduced-motion). - Home screen: mood-mode nav (Today/Wonder/People Helping/Solutions/Light Only/Grounded), the daily brief as a hero + remaining four, browsable mood lanes, an explicit calm end-state, inline Not today / Less like this / Hide affordances, and device-local Calm Filters mirroring goodnews/filters.py. - Backend: moods.py + GET /api/moods (single source of truth for the modes); FilterPrefs gains max_cortisol/max_ragebait ceilings (for Light Only). - Push categorical filters (include/mute topics+flavors, ceilings) into SQL in queries.feed so low-ranked-but-matching items (e.g. discovery for Wonder) are not truncated by ranking; only avoid-terms stay a Python pass. - PWA manifest + icon (installable; offline deferred per plan). - Multi-stage Dockerfile builds the site then serves it from the API. - Tests: queries.feed categorical filters (63 total). README updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+35
-8
@@ -31,11 +31,15 @@ from . import feeds, queries
|
||||
from .db import connect, init_db
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
from .llm import LocalModelClient
|
||||
from .moods import MOODS
|
||||
from .taxonomy import FLAVORS, TOPICS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
# Prefer the built SvelteKit site; fall back to the legacy single-page harness.
|
||||
FRONTEND_DIR = ROOT / "frontend" / "build"
|
||||
LEGACY_STATIC = Path(__file__).resolve().parent / "static"
|
||||
STATIC_DIR = FRONTEND_DIR if FRONTEND_DIR.is_dir() else LEGACY_STATIC
|
||||
|
||||
|
||||
def db_path() -> Path:
|
||||
@@ -188,6 +192,12 @@ def create_app() -> FastAPI:
|
||||
flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()],
|
||||
)
|
||||
|
||||
@app.get("/api/moods")
|
||||
def moods() -> list[dict]:
|
||||
# The humane front door: each mood resolves to a filter preset the
|
||||
# client merges with the user's own Calm Filters.
|
||||
return MOODS
|
||||
|
||||
@app.get("/api/category-counts", response_model=list[CategoryCount])
|
||||
def category_counts(accepted_only: bool = True, prefs: str | None = Query(None)) -> list[CategoryCount]:
|
||||
fp = prefs_from_json(prefs)
|
||||
@@ -220,20 +230,37 @@ def create_app() -> FastAPI:
|
||||
if flavor and flavor.lower() not in FLAVORS:
|
||||
raise HTTPException(400, f"unknown flavor: {flavor}")
|
||||
fp = prefs_from_json(prefs)
|
||||
now = datetime.now(timezone.utc)
|
||||
with get_conn() as conn:
|
||||
if fp.is_empty():
|
||||
rows = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=limit, offset=offset
|
||||
)
|
||||
else:
|
||||
# Over-fetch, apply the calm filters in Python (word-boundary
|
||||
# avoid-terms can't be done in SQL), then slice to the page.
|
||||
fetch_n = min(2000, (offset + limit) * 4 + 50)
|
||||
raw = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=fetch_n, offset=0
|
||||
# Categorical filters (include/mute topics+flavors incl. active
|
||||
# pauses, cortisol ceiling) go to SQL so nothing is truncated by
|
||||
# ranking. Only word-boundary avoid-terms need a Python pass, so
|
||||
# over-fetch just enough to cover what they might remove.
|
||||
kw = dict(
|
||||
include_topics=fp.include_topics or None,
|
||||
include_flavors=fp.include_flavors or None,
|
||||
mute_topics=list(fp.muted_topics(now)) or None,
|
||||
mute_flavors=list(fp.muted_flavors(now)) or None,
|
||||
max_cortisol=fp.max_cortisol,
|
||||
max_ragebait=fp.max_ragebait,
|
||||
)
|
||||
filtered = filter_articles(raw, fp, datetime.now(timezone.utc))
|
||||
rows = filtered[offset : offset + limit]
|
||||
if fp.avoid_terms:
|
||||
raw = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
||||
limit=min(2000, (offset + limit) * 4 + 50), offset=0, **kw,
|
||||
)
|
||||
kept = filter_articles(raw, fp, now) # drops avoid-term matches
|
||||
rows = kept[offset : offset + limit]
|
||||
else:
|
||||
rows = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
||||
limit=limit, offset=offset, **kw,
|
||||
)
|
||||
return FeedResponse(
|
||||
topic=topic,
|
||||
flavor=flavor,
|
||||
|
||||
@@ -69,12 +69,20 @@ class FilterPrefs:
|
||||
mute_flavors: list[str] = field(default_factory=list)
|
||||
avoid_terms: list[str] = field(default_factory=list)
|
||||
pauses: list[Pause] = field(default_factory=list)
|
||||
max_cortisol: int | None = None
|
||||
max_ragebait: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict | None) -> "FilterPrefs":
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
def _opt_int(value: object) -> int | None:
|
||||
try:
|
||||
return int(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _str_list(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
@@ -96,6 +104,8 @@ class FilterPrefs:
|
||||
mute_flavors=_str_list(data.get("mute_flavors")),
|
||||
avoid_terms=_str_list(data.get("avoid_terms")),
|
||||
pauses=pauses,
|
||||
max_cortisol=_opt_int(data.get("max_cortisol")),
|
||||
max_ragebait=_opt_int(data.get("max_ragebait")),
|
||||
)
|
||||
|
||||
def muted_topics(self, now: datetime) -> set[str]:
|
||||
@@ -117,6 +127,8 @@ class FilterPrefs:
|
||||
or self.mute_flavors
|
||||
or self.avoid_terms
|
||||
or self.pauses
|
||||
or self.max_cortisol is not None
|
||||
or self.max_ragebait is not None
|
||||
)
|
||||
|
||||
|
||||
@@ -148,6 +160,10 @@ def allows(article: dict, prefs: FilterPrefs, now: datetime) -> bool:
|
||||
return False
|
||||
if flavor in prefs.muted_flavors(now):
|
||||
return False
|
||||
if prefs.max_cortisol is not None and (article.get("cortisol_score") or 0) > prefs.max_cortisol:
|
||||
return False
|
||||
if prefs.max_ragebait is not None and (article.get("ragebait_score") or 0) > prefs.max_ragebait:
|
||||
return False
|
||||
blob = f"{article.get('title') or ''} {article.get('description') or ''}"
|
||||
if text_matches_avoid_terms(blob, prefs.avoid_terms):
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Mood modes — the humane front door over the topic/flavor taxonomy.
|
||||
|
||||
A reader thinks "I want wonder," not "animals/discovery". Each mood resolves to
|
||||
a filter preset (include_topics / include_flavors / a cortisol ceiling) that the
|
||||
feed already understands via FilterPrefs. Topic/flavor remain available as the
|
||||
secondary "browse more precisely" controls; moods don't replace them.
|
||||
|
||||
Single source of truth so the website and any future companion app agree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# "today" is special: it has no filter — it's the daily brief view.
|
||||
MOODS: list[dict] = [
|
||||
{
|
||||
"key": "today",
|
||||
"label": "Today",
|
||||
"description": "The day's five good things.",
|
||||
"filter": {},
|
||||
},
|
||||
{
|
||||
"key": "wonder",
|
||||
"label": "Wonder",
|
||||
"description": "Awe and discovery.",
|
||||
"filter": {"include_topics": ["science", "animals", "culture"], "include_flavors": ["discovery"]},
|
||||
},
|
||||
{
|
||||
"key": "people-helping",
|
||||
"label": "People Helping",
|
||||
"description": "Community, kindness, and repair.",
|
||||
"filter": {"include_topics": ["community"], "include_flavors": ["solution", "feelgood"]},
|
||||
},
|
||||
{
|
||||
"key": "solutions",
|
||||
"label": "Solutions",
|
||||
"description": "Problems being solved.",
|
||||
"filter": {
|
||||
"include_topics": ["environment", "community", "health"],
|
||||
"include_flavors": ["solution", "breakthrough"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "light",
|
||||
"label": "Light Only",
|
||||
"description": "Just the gentle stuff.",
|
||||
"filter": {"include_flavors": ["feelgood", "discovery"], "max_cortisol": 2},
|
||||
},
|
||||
{
|
||||
"key": "grounded",
|
||||
"label": "Grounded",
|
||||
"description": "Useful, calm perspective.",
|
||||
"filter": {"include_flavors": ["perspective", "solution"]},
|
||||
},
|
||||
]
|
||||
|
||||
_BY_KEY = {m["key"]: m for m in MOODS}
|
||||
|
||||
|
||||
def mood_filter(key: str) -> dict:
|
||||
"""Return the filter preset for a mood key (empty dict if unknown/today)."""
|
||||
mood = _BY_KEY.get(key)
|
||||
return dict(mood["filter"]) if mood else {}
|
||||
+38
-2
@@ -47,8 +47,20 @@ def feed(
|
||||
accepted_only: bool = True,
|
||||
limit: int = 30,
|
||||
offset: int = 0,
|
||||
include_topics: list[str] | None = None,
|
||||
include_flavors: list[str] | None = None,
|
||||
mute_topics: list[str] | None = None,
|
||||
mute_flavors: list[str] | None = None,
|
||||
max_cortisol: int | None = None,
|
||||
max_ragebait: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return ranked articles, optionally filtered by topic and/or flavor."""
|
||||
"""Return ranked articles with categorical filters applied in SQL.
|
||||
|
||||
Categorical filters (topic/flavor include & mute, cortisol/ragebait ceilings)
|
||||
must be applied here, not after ranking — otherwise low-ranked-but-matching
|
||||
items (e.g. 'discovery' for a Wonder lane) fall outside any over-fetch window.
|
||||
Word-boundary avoid-terms remain a Python pass on the caller side.
|
||||
"""
|
||||
clauses = ["a.duplicate_of IS NULL"]
|
||||
params: list = []
|
||||
if accepted_only:
|
||||
@@ -59,7 +71,31 @@ def feed(
|
||||
if flavor:
|
||||
clauses.append("s.flavor = ?")
|
||||
params.append(flavor.lower())
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
|
||||
def _in(column: str, values: list[str], negate: bool = False) -> None:
|
||||
vals = [v.lower() for v in values]
|
||||
placeholders = ",".join("?" * len(vals))
|
||||
op = "NOT IN" if negate else "IN"
|
||||
# COALESCE keeps NULL-category rows from being dropped by NOT IN.
|
||||
clauses.append(f"COALESCE({column}, '') {op} ({placeholders})")
|
||||
params.extend(vals)
|
||||
|
||||
if include_topics:
|
||||
_in("s.topic", include_topics)
|
||||
if include_flavors:
|
||||
_in("s.flavor", include_flavors)
|
||||
if mute_topics:
|
||||
_in("s.topic", mute_topics, negate=True)
|
||||
if mute_flavors:
|
||||
_in("s.flavor", mute_flavors, negate=True)
|
||||
if max_cortisol is not None:
|
||||
clauses.append("COALESCE(s.cortisol_score, 0) <= ?")
|
||||
params.append(max_cortisol)
|
||||
if max_ragebait is not None:
|
||||
clauses.append("COALESCE(s.ragebait_score, 0) <= ?")
|
||||
params.append(max_ragebait)
|
||||
|
||||
where = "WHERE " + " AND ".join(clauses)
|
||||
params.extend([limit, offset])
|
||||
|
||||
rows = conn.execute(
|
||||
|
||||
Reference in New Issue
Block a user