Sortable triage, and say which sort is active

The list was ordered by max(interest, importance) and nothing on the page said
so, which makes it impossible to tell whether an odd ranking is a bug or the
intended order.

Five orders, with the active one explained in a line beneath the control:

  best       highest of the two scores (unchanged default)
  interest   purely the taste profile, importance ignored entirely
  important  purely the importance axis -- velocity, corroboration, prominence
  new        most recently seen, regardless of score
  covered    most independent newsrooms, syndicated copy collapsed

"My interests" is the one that was actually missing. The two-axis design means a
quiet story that is very much the reader's can sit below a big one that is not,
and until now there was no way to see past that.

An unknown sort falls back to the default rather than erroring.
This commit is contained in:
connor committed 2026-09-08 18:13:36 -04:00
1 parent 90077aa3bd
commit 161c518aa7
3 files changed
+104 -7

No files matched your search

+25 -5
View File
@@ -15,7 +15,14 @@
.stats, .filters { color:var(--muted); font-size:.83rem; }
.filters a { color:var(--accent); text-decoration:none; margin-left:.6rem; }
.subbar { display:flex; gap:1rem; flex-wrap:wrap; justify-content:space-between;
margin:-.4rem 0 1rem; }
align-items:center; margin:-.4rem 0 .35rem; }
.controls { display:flex; gap:.7rem; align-items:center; }
.controls label { font-size:.78rem; color:var(--muted); display:flex; gap:.3rem;
align-items:center; }
.controls select { font:inherit; font-size:.8rem; background:var(--card);
color:var(--fg); border:1px solid var(--rule);
border-radius:5px; padding:.15rem .3rem; }
.sortnote { color:var(--muted); font-size:.78rem; margin:0 0 1rem; }
.filters a.on { text-decoration:underline; }
article { background:var(--card); border:1px solid var(--rule); border-radius:9px;
@@ -70,11 +77,24 @@
</header>
<div class="subbar">
<span class="stats">{{ rows|length }} shown · {{ labelled }} labelled · {{ total }} clusters</span>
<span class="filters">show
<a class="{{ 'on' if show == 'unlabelled' }}" href="?show=unlabelled">unlabelled</a>
<a class="{{ 'on' if show == 'all' }}" href="?show=all">all</a>
</span>
<form class="controls" method="get">
<label>sort
<select name="sort" onchange="this.form.submit()">
{% for value, label in sorts %}
<option value="{{ value }}" {{ 'selected' if sort == value }}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>show
<select name="show" onchange="this.form.submit()">
<option value="unlabelled" {{ 'selected' if show == 'unlabelled' }}>Unlabelled</option>
<option value="all" {{ 'selected' if show == 'all' }}>All</option>
</select>
</label>
<noscript><button>Apply</button></noscript>
</form>
</div>
<p class="sortnote">{{ sort_help }}</p>
{% for r in rows %}
<article id="c{{ loop.index0 }}" data-id="{{ r.id }}"
+27 -2
View File
@@ -100,7 +100,20 @@ async def triage(request):
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))
# Sort orders. "best" is the default and mixes both axes; "interest" is the
# one that answers "what is mine, ignoring whether the world thinks it
# matters", which the two-axis design otherwise hides.
SORTS = {
"best": lambda s: -max(s.interest_score, s.importance_score),
"interest": lambda s: -s.interest_score,
"important": lambda s: -s.importance_score,
"new": lambda s: -(s.last_seen.timestamp() if s.last_seen else 0),
"covered": lambda s: (-s.corroboration.origins, -s.interest_score),
}
sort = request.query_params.get("sort", "best")
if sort not in SORTS:
sort = "best"
ranked = sorted(scores, key=SORTS[sort])
rows = []
for s in ranked:
if s.veto:
@@ -141,7 +154,19 @@ async def triage(request):
return TEMPLATES.TemplateResponse(
request, "triage.html",
{"rows": rows, "show": show, "total": len(scores), "labelled": len(labelled),
"signed": bool(secret), "current": "triage"})
"signed": bool(secret), "current": "triage", "sort": sort,
"sort_help": {
"best": "Highest of the two scores — mixes what you like with what matters.",
"interest": "Purely your profile. Ignores importance entirely, so a quiet "
"story that is very much yours outranks a big one that is not.",
"important": "Purely the importance axis — burst velocity, independent "
"corroboration, editorial prominence. Largely world news.",
"new": "Most recently seen first, regardless of score.",
"covered": "Most independent newsrooms first, syndicated copy collapsed.",
}[sort],
"sorts": [("best", "Best match"), ("interest", "My interests"),
("important", "Most important"), ("new", "Newest first"),
("covered", "Most covered")]})
async def tune(request):
+52
View File
@@ -302,3 +302,55 @@ def test_triage_cards_expand_to_every_report(tmp_path, monkeypatch):
assert f"Report number {n}" in html, f"report {n} missing from the card"
assert "<details" in html and "4 more reports" in html
assert "across 7 outlets" in html
def _triage_db(tmp_path, monkeypatch):
"""Three clusters that rank differently under each sort order."""
import numpy as np
from datetime import UTC, datetime, timedelta
monkeypatch.setenv("NEWS_DB", str(tmp_path / "s.db"))
monkeypatch.setenv("NEWS_FEEDBACK_SECRET", SECRET)
con = store.connect(tmp_path / "s.db")
now = datetime.now(UTC)
for n in range(3):
store.upsert_source(con, id=f"s{n}", name=f"O{n}", url=f"http://s{n}", kind="rss",
bucket=f"b{n}", weight=1.0, prominence=0, enabled=1,
poll_interval_secs=900)
# c_old is oldest, c_new is newest.
for cid, age_h in (("c_old", 30), ("c_mid", 10), ("c_new", 1)):
when = (now - timedelta(hours=age_h)).isoformat()
con.execute("""INSERT INTO items (id,source_id,title,summary,url,canonical_url,
published_at,first_seen_at,content_hash)
VALUES (?,?,?,'',?,?,?,?,?)""",
(cid, "s0", f"Story {cid}", f"http://x/{cid}", f"http://x/{cid}",
when, when, cid))
con.execute("""INSERT INTO clusters (id,centroid,size,first_seen_at,last_seen_at,
lead_item_id) VALUES (?,?,1,?,?,?)""",
(cid, store.to_blob(np.zeros(384, dtype="float32")), when, when, cid))
con.execute("INSERT INTO cluster_members VALUES (?,?,?)", (cid, cid, when))
from news import web
web._VECTORS = None
return TestClient(web.app)
def test_newest_first_orders_by_recency(tmp_path, monkeypatch):
html = _triage_db(tmp_path, monkeypatch).get("/?sort=new&show=all").text
order = [t for t in ("c_new", "c_mid", "c_old") if f"Story {t}" in html]
positions = [html.index(f"Story {t}") for t in order]
assert positions == sorted(positions), "newest-first must be strictly by recency"
assert html.index("Story c_new") < html.index("Story c_old")
def test_sort_control_offers_every_order_and_marks_the_active_one(tmp_path, monkeypatch):
c = _triage_db(tmp_path, monkeypatch)
html = c.get("/?sort=interest").text
for value in ("best", "interest", "important", "new", "covered"):
assert f'value="{value}"' in html, f"sort option {value} missing"
assert 'value="interest" selected' in html
assert "Ignores importance entirely" in html, "the active sort must explain itself"
def test_unknown_sort_falls_back_rather_than_erroring(tmp_path, monkeypatch):
html = _triage_db(tmp_path, monkeypatch).get("/?sort=../../etc/passwd").text
assert 'value="best" selected' in html