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 @@
+
+
+
+
+ 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),