diff --git a/README.md b/README.md index df9f523..1b79194 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,19 @@ Re-run the calibration in `profile/CALIBRATION.md` before trusting them. `simulate` is the tuning surface. The old system's replay harness hardcoded its parameters and so could never sweep the thresholds it existed to tune. +## Web + + / triage feed -- ranked, keyboard-driven (j/k move, i/n label, o open) + /tune move the thresholds and budgets, see what would have been sent + /digest the day's briefing, mobile-first + /healthz + +Everything except `/api/feedback/*` sits behind the identity gateway. That one +path is exempt because ntfy runs notification buttons from the phone and a +native app cannot complete an interactive login; it is HMAC-signed instead. + ## Status -Ingest, embedding, clustering, two-axis scoring and routing are built and -running against live feeds. Delivery (ntfy, the digest page) and the web UI are -not yet written. +Running in production. Ingest, clustering, two-axis scoring, routing, ntfy +delivery with working feedback buttons, the digest page and the triage UI are +all live. diff --git a/news/templates/triage.html b/news/templates/triage.html new file mode 100644 index 0000000..78d79c5 --- /dev/null +++ b/news/templates/triage.html @@ -0,0 +1,153 @@ + + + +Triage + +
+
+

Triage

+
{{ rows|length }} shown · {{ labelled }} labelled · {{ total }} clusters
+
show + unlabelled + all + · digest +
+
+ + {% for r in rows %} +
+

{{ r.headline }}

+
+ {{ r.lane }} + {{ r.topic }} · int {{ '%.2f'|format(r.interest) }} + {{ r.importance_topic }} · imp {{ '%.2f'|format(r.importance) }} + {{ r.origins }} origin{{ '' if r.origins == 1 else 's' }} + {% if r.demote %}demoted: {{ r.demote }}{% endif %} +
+ +
+ + +
+
+ {% else %} +

Nothing left to label. Try show all.

+ {% endfor %} +
+ +
+ j/k move + i interested + n not interested + o open +
+ + diff --git a/news/templates/tune.html b/news/templates/tune.html new file mode 100644 index 0000000..25ac122 --- /dev/null +++ b/news/templates/tune.html @@ -0,0 +1,136 @@ + + + +Tune + +
+
+

Tune

+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+ · over the last {{ '%.0f'|format(days * 24) }}h of clusters

+
+ +
+
{{ '%.1f'|format(per_day) }}notifications/day
+
+ {{ counts['alert:important'] }} importance · {{ counts['alert:interest'] }} interest · + {{ counts['digest'] }} digest · {{ counts['drop'] }} dropped +
+
+ +

Would notify

+ + +

Would go to the digest

+ + +

+ Sliders are a simulation only — nothing here writes to config.toml. +

+

+ Three constraints compete, and which one actually binds is not obvious. + Measured on this corpus: at the shipped budgets (3 interest, 2 importance) + the budget binds and the subject quota is inert — moving the + quota from 1 to 3 changes nothing. Raise the budgets to 8 and the + quota takes over, at which point 1 / 2 / 3 yields 10 / 11 / + 13 alerts. The threshold binds last in both cases: there is + almost always more above-threshold news than there are slots, so it works as + a quality floor for slow days rather than as a volume control. +

+

+ So to get more notifications, raise the budgets first. To get more + variety at the same volume, lower the budgets and raise the quota. +

+
diff --git a/news/web.py b/news/web.py index 85344f8..673671a 100644 --- a/news/web.py +++ b/news/web.py @@ -28,6 +28,22 @@ def _con(): return store.connect(Config().db_path) +# Scoring a page load is cheap (matrix ops over ~1,300 clusters), but building +# ProfileVectors loads the embedding model. Cached so only the first request +# pays for it. +_VECTORS = None + + +def _scored(con, cfg): + global _VECTORS + from .embed import Embedder + from .score import ProfileVectors, score_clusters + + if _VECTORS is None: + _VECTORS = ProfileVectors(con, cfg.profile(), Embedder.from_config(cfg)) + return score_clusters(con, _VECTORS, cfg.scoring, cfg.clustering) + + async def healthz(request): return PlainTextResponse("ok") @@ -60,6 +76,138 @@ async def feedback(request): return JSONResponse({"ok": True, "cluster_id": cluster_id, "kind": kind}) +async def triage(request): + """The labelling machine. + + This is where feedback volume comes from, so it is built to be fast to skim + and label: ranked, keyboard-driven, one keystroke per judgement. The old + system's UI could only be clicked, and its clicks did not persist. + """ + from datetime import UTC, datetime + + from .route import route + + cfg = Config() + con = _con() + scores = _scored(con, cfg) + decisions = {d.cluster_id: d for d in + route(con, scores, cfg.routing, cfg.delivery, + datetime.now(UTC), cfg.block_groups())} + + labelled = {r["cluster_id"] for r in con.execute("SELECT DISTINCT cluster_id FROM feedback")} + show = request.query_params.get("show", "unlabelled") + limit = min(int(request.query_params.get("limit", 60)), 200) + + ranked = sorted(scores, key=lambda s: -max(s.interest_score, s.importance_score)) + rows = [] + for s in ranked: + if s.veto: + continue + if show == "unlabelled" and s.cluster_id in labelled: + continue + d = decisions.get(s.cluster_id) + members = con.execute( + """SELECT i.title, i.url, so.name FROM cluster_members cm + JOIN items i ON i.id = cm.item_id + JOIN sources so ON so.id = i.source_id + WHERE cm.cluster_id = ? LIMIT 5""", (s.cluster_id,)).fetchall() + rows.append({ + "id": s.cluster_id, "headline": s.lead_title, + "topic": s.interest.block, "importance_topic": s.importance.block, + "interest": s.interest_score, "importance": s.importance_score, + "origins": s.corroboration.origins, "demote": s.demote, + "lane": d.lane if d else "?", "reason": d.reason if d else "", + "labelled": s.cluster_id in labelled, + "sources": [{"name": m["name"], "title": m["title"], "url": m["url"]} + for m in members], + }) + if len(rows) >= limit: + break + + return TEMPLATES.TemplateResponse( + request, "triage.html", + {"rows": rows, "show": show, "total": len(scores), "labelled": len(labelled)}) + + +async def web_feedback(request): + """Feedback from the browser. + + Deliberately NOT under /api/feedback/, which Caddy routes around Authelia + for the ntfy buttons. This path stays behind the identity gateway, so it + needs no signature. + """ + from datetime import UTC, datetime + + kind = request.path_params["kind"] + if kind not in ("interested", "not_interested"): + return JSONResponse({"error": "bad kind"}, status_code=400) + con = _con() + con.execute( + "INSERT INTO feedback (cluster_id, kind, source, created_at) VALUES (?,?,?,?)", + (request.path_params["cluster_id"], kind, "web", datetime.now(UTC).isoformat())) + return JSONResponse({"ok": True}) + + +async def tune(request): + """Threshold tuning against real history. + + The old system's replay harness hardcoded its parameters, so it could never + sweep the thresholds it existed to tune. Here the sliders drive the same + `route()` the live service calls, so what you see is what it would have sent. + """ + from datetime import UTC, datetime + + from .route import DIGEST, DROP, IMPORTANT, INTEREST, route + + cfg = Config() + con = _con() + scores = _scored(con, cfg) + + routing = dict(cfg.routing) + for key in ("interest_threshold", "important_threshold", "digest_floor"): + if (raw := request.query_params.get(key)) is not None: + try: + routing[key] = float(raw) + except ValueError: + pass + for key in ("interest_per_day", "important_per_day", "digest_max_items", + "max_alerts_per_group_per_day"): + if (raw := request.query_params.get(key)) is not None: + try: + routing[key] = int(raw) + except ValueError: + pass + + now = datetime.now(UTC) + decisions = route(con, scores, routing, cfg.delivery, now, cfg.block_groups()) + by_id = {s.cluster_id: s for s in scores} + lanes: dict[str, list] = {} + for d in decisions: + lanes.setdefault(d.lane, []).append(d) + + days = routing["max_age_hours"] / 24.0 + counts = {lane: len(lanes.get(lane, [])) for lane in (IMPORTANT, INTEREST, DIGEST, DROP)} + alerts = [ + {"lane": d.lane, "reason": d.reason, "score": d.score, + "headline": by_id[d.cluster_id].lead_title, + "topic": by_id[d.cluster_id].interest.block} + for d in sorted(lanes.get(IMPORTANT, []) + lanes.get(INTEREST, []), + key=lambda d: -d.score) + ] + digest = [ + {"reason": d.reason, "score": d.score, + "headline": by_id[d.cluster_id].lead_title, + "topic": by_id[d.cluster_id].interest.block} + for d in sorted(lanes.get(DIGEST, []), key=lambda d: -d.score)[:25] + ] + + return TEMPLATES.TemplateResponse( + request, "tune.html", + {"routing": routing, "counts": counts, "alerts": alerts, "digest": digest, + "per_day": (counts[IMPORTANT] + counts[INTEREST]) / days, "days": days, + "defaults": cfg.routing}) + + async def digest_page(request): day = request.path_params.get("day") try: @@ -107,6 +255,9 @@ async def digest_page(request): routes = [ + Route("/", triage), + Route("/ui/feedback/{cluster_id}/{kind}", web_feedback, methods=["POST"]), + Route("/tune", tune), Route("/healthz", healthz), Route("/api/feedback/{cluster_id}/{kind}/{token}", feedback, methods=["POST"]), Route("/digest", digest_page),