From 42c38d759d0304a1a8caafde0e41056c0f21a40f Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Tue, 8 Sep 2026 17:18:13 -0400 Subject: [PATCH] Phase 5: triage feed and the tuning surface / is the labelling machine: ranked clusters, one keystroke per judgement (j/k move, i/n label, o open), advancing automatically so a run of judgements is a run of single keys. Feedback volume is the whole point -- the old system collected 32 labels because its buttons did not persist and its UI could only be clicked. Browser feedback posts to /ui/feedback/, deliberately NOT under /api/feedback/, which Caddy routes around Authelia for the ntfy buttons. The browser path stays behind the identity gateway and so needs no signature. /tune drives the same route() the live service calls, so what it shows is what would have been sent. The old replay harness hardcoded its parameters and could never sweep the thresholds it existed to tune. Building it corrected a claim I had already made twice. I said the budgets bind before the thresholds, then wrote that the subject quota binds before the budget. Measured on this corpus, neither is generally true: at the shipped budgets (3 interest, 2 importance) the BUDGET binds and the quota is inert -- moving it 1 to 3 changes nothing. Raise budgets to 8 and the QUOTA takes over, 1/2/3 giving 10/11/13 alerts. The page now says exactly that, with the numbers, rather than a rule of thumb that happened to be wrong. --- README.md | 17 ++++- news/templates/triage.html | 153 +++++++++++++++++++++++++++++++++++++ news/templates/tune.html | 136 +++++++++++++++++++++++++++++++++ news/web.py | 151 ++++++++++++++++++++++++++++++++++++ 4 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 news/templates/triage.html create mode 100644 news/templates/tune.html 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 %} +
+
    + {% for s in r.sources %} +
  • {{ s.title }} + — {{ s.name }}
  • + {% endfor %} +
+
+ + +
+
+ {% 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),