Calm Filters MVP: device-local personalization across feed/brief/counts
- API endpoints (feed, brief, category-counts) accept a 'prefs' JSON query param, parsed tolerantly into FilterPrefs (bad blobs never break the feed). - Feed over-fetches then applies word-boundary filters in Python and slices to the page; brief is filtered down (no refill); counts are computed over the same filtered set so browse numbers match the feed exactly. - Pause.active() coerces naive datetimes to UTC; FilterPrefs.from_dict skips malformed pauses and non-string list entries. - Static site adds the humane ladder (Not today / Less like this / Always hide) plus a Calm filters panel managing pauses, mutes, and avoid-terms in localStorage. Nothing leaves the device. - Tests now 38 (added forgiving-parse and naive-now cases). README documents it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+43
-12
@@ -15,7 +15,9 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
@@ -25,6 +27,7 @@ from pydantic import BaseModel
|
||||
|
||||
from . import queries
|
||||
from .db import connect, init_db
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
from .taxonomy import FLAVORS, TOPICS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -148,9 +151,21 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
@app.get("/api/category-counts", response_model=list[CategoryCount])
|
||||
def category_counts(accepted_only: bool = True) -> list[CategoryCount]:
|
||||
def category_counts(accepted_only: bool = True, prefs: str | None = Query(None)) -> list[CategoryCount]:
|
||||
fp = prefs_from_json(prefs)
|
||||
with get_conn() as conn:
|
||||
rows = queries.category_counts(conn, accepted_only=accepted_only)
|
||||
if fp.is_empty():
|
||||
rows = queries.category_counts(conn, accepted_only=accepted_only)
|
||||
else:
|
||||
# Count over the SAME filtered set the feed would return, so the
|
||||
# browse numbers always match what the user actually sees.
|
||||
allrows = queries.feed(conn, accepted_only=accepted_only, limit=100000, offset=0)
|
||||
kept = filter_articles(allrows, fp, datetime.now(timezone.utc))
|
||||
counts = Counter((r["topic"], r["flavor"]) for r in kept)
|
||||
rows = [
|
||||
{"topic": t, "flavor": f, "count": n}
|
||||
for (t, f), n in sorted(counts.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1])))
|
||||
]
|
||||
return [CategoryCount(**row) for row in rows]
|
||||
|
||||
@app.get("/api/feed", response_model=FeedResponse)
|
||||
@@ -160,20 +175,27 @@ def create_app() -> FastAPI:
|
||||
accepted_only: bool = True,
|
||||
limit: int = Query(30, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
prefs: str | None = Query(None),
|
||||
) -> FeedResponse:
|
||||
if topic and topic.lower() not in TOPICS:
|
||||
raise HTTPException(400, f"unknown topic: {topic}")
|
||||
if flavor and flavor.lower() not in FLAVORS:
|
||||
raise HTTPException(400, f"unknown flavor: {flavor}")
|
||||
fp = prefs_from_json(prefs)
|
||||
with get_conn() as conn:
|
||||
rows = queries.feed(
|
||||
conn,
|
||||
topic=topic,
|
||||
flavor=flavor,
|
||||
accepted_only=accepted_only,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
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
|
||||
)
|
||||
filtered = filter_articles(raw, fp, datetime.now(timezone.utc))
|
||||
rows = filtered[offset : offset + limit]
|
||||
return FeedResponse(
|
||||
topic=topic,
|
||||
flavor=flavor,
|
||||
@@ -182,13 +204,22 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
@app.get("/api/brief", response_model=BriefResponse)
|
||||
def brief(date: str | None = Query(None), limit: int = Query(10, ge=1, le=50)) -> BriefResponse:
|
||||
def brief(
|
||||
date: str | None = Query(None),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
prefs: str | None = Query(None),
|
||||
) -> BriefResponse:
|
||||
fp = prefs_from_json(prefs)
|
||||
with get_conn() as conn:
|
||||
data = queries.brief(conn, brief_date=date, limit=limit)
|
||||
items = data["items"]
|
||||
if not fp.is_empty():
|
||||
# MVP: filter the stored brief DOWN; no refill from outside the brief.
|
||||
items = filter_articles(items, fp, datetime.now(timezone.utc))
|
||||
return BriefResponse(
|
||||
brief_date=data["brief_date"],
|
||||
title=data["title"],
|
||||
items=[Article.from_row(r) for r in data["items"]],
|
||||
items=[Article.from_row(r) for r in items],
|
||||
)
|
||||
|
||||
@app.get("/api/brief-dates", response_model=list[str])
|
||||
|
||||
Reference in New Issue
Block a user