Narrow the window to 24 hours everywhere a reader sees it
Notifications, the digest and the triage queue now share one 24h window. The app is built on a daily cadence -- the recent news of today -- and 48h meant the digest could carry a story purely because the window was wider than the rhythm the app runs on. Measured on a live day: 7 of 16 carried-over digest stories age out. The triage dropdown keeps 48h / 7 days / everything for catching up after a few days away; only the default moves. [clustering] window_hours stays at 48 deliberately. That governs how far back a new item looks for a cluster to join, not what reaches the reader, and cutting it would split a two-day story into two clusters instead of keeping it as one. Note what this does NOT fix: staleness is measured on the cluster's last_seen_at, refreshed whenever a new report joins, so a story reported on again this morning stays inside the window however long it has been running. That is correct -- an ongoing story is today's news -- but it means 9 of those 16 carry-overs survive, and repeat suppression is still a separate question. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xPLgmcXCc9JjVwmQF63eM
This commit is contained in:
1 parent
f1242065ec
commit
d78eec0b92
4 files changed
+42
-20
No files matched your search
+6
-5
@@ -119,11 +119,12 @@ class Config:
|
||||
"important_threshold": r.get("important_threshold", 2.0),
|
||||
"digest_floor": r.get("digest_floor", 0.5),
|
||||
"hn_min_score": r.get("hn_min_score", 150),
|
||||
"max_age_hours": r.get("max_age_hours", 48),
|
||||
# How far back the triage queue reaches. Distinct from
|
||||
# max_age_hours: that one governs what may INTERRUPT you, this one
|
||||
# governs what is still worth labelling. Defaults to the same 48h.
|
||||
"triage_window_hours": r.get("triage_window_hours", 48),
|
||||
# The reader-facing window: notifications, digest and triage all
|
||||
# use it. 24h, because the app is built on a daily cadence.
|
||||
"max_age_hours": r.get("max_age_hours", 24),
|
||||
# Kept separate so the labelling surface can be widened without
|
||||
# widening delivery, but normally the same value.
|
||||
"triage_window_hours": r.get("triage_window_hours", 24),
|
||||
"digest_max_items": r.get("digest_max_items", 25),
|
||||
"digest_max_per_group": r.get("digest_max_per_group", 4),
|
||||
"digest_importance_slots": r.get("digest_importance_slots", 6),
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ def route(
|
||||
def defer(s: ClusterScore, reason: str) -> None:
|
||||
deferred[s.cluster_id] = reason
|
||||
|
||||
max_age = timedelta(hours=routing.get("max_age_hours", 48))
|
||||
max_age = timedelta(hours=routing.get("max_age_hours", 24))
|
||||
live: list[ClusterScore] = []
|
||||
for s in scores:
|
||||
# Staleness first. Feeds hand back large archives on first fetch -- one
|
||||
|
||||
+26
-9
@@ -120,22 +120,39 @@ serendipity_max_interest = 0.5
|
||||
# would reject most of what the crowd has actually promoted.
|
||||
hn_min_score = 100
|
||||
|
||||
# The window. Every reader-facing surface uses it: notifications, the digest,
|
||||
# and the triage queue. This app is built on a daily cadence -- "the recent news
|
||||
# of today" -- so 24, not 48.
|
||||
#
|
||||
# Nothing older than this may generate a notification. Feeds return large
|
||||
# archives on first fetch -- one run pulled 88 items going back five months --
|
||||
# and those belong in the scoring corpus but not in your pocket.
|
||||
max_age_hours = 48
|
||||
#
|
||||
# NOTE this is measured on the cluster's last_seen_at, which is refreshed every
|
||||
# time a new report joins it, so a story with fresh coverage today stays inside
|
||||
# the window however long it has been running. That is deliberate -- an ongoing
|
||||
# story IS today's news -- but it means this setting alone does not stop the
|
||||
# digest repeating a long-running story. Measured on a real day: cutting 48 -> 24
|
||||
# aged out 7 of 16 carried-over digest stories; the other 9 had been reported on
|
||||
# again that morning and stayed, correctly.
|
||||
#
|
||||
# Distinct from [clustering] window_hours, which stays at 48 on purpose: that is
|
||||
# how far back a NEW item looks for a cluster to join, and shortening it would
|
||||
# split a two-day story into two clusters rather than keeping it as one.
|
||||
max_age_hours = 24
|
||||
|
||||
# How far back the TRIAGE queue reaches. Separate from max_age_hours, which
|
||||
# governs what may interrupt you; this governs what is still worth labelling.
|
||||
# How far back the TRIAGE queue reaches. Normally the same as max_age_hours --
|
||||
# nothing older can reach you, so nothing older needs labelling -- but kept
|
||||
# separate so you can widen the labelling surface without widening delivery.
|
||||
#
|
||||
# Not optional in practice. Scoring covers every cluster ever stored, on
|
||||
# purpose -- the z-score population should describe what news looks like, not
|
||||
# only the last two days -- but with no window the page rendered that whole
|
||||
# archive ranked by score. Nothing aged out, so a strong story from day one
|
||||
# still led the queue on day three, and 11 of the first 60 items were feed
|
||||
# backfill published before the system existed. The dropdown on the page
|
||||
# overrides this per visit.
|
||||
triage_window_hours = 48
|
||||
# only the last day -- but with no window the page rendered that whole archive
|
||||
# ranked by score. Nothing aged out, so a strong story from day one still led
|
||||
# the queue on day three, and 11 of the first 60 items were feed backfill
|
||||
# published before the system existed. The dropdown on the page overrides this
|
||||
# per visit, for catching up after a few days away.
|
||||
triage_window_hours = 24
|
||||
|
||||
[scoring.importance_weights]
|
||||
# Importance is NOT corroboration count -- that was the old system's error.
|
||||
|
||||
@@ -335,7 +335,9 @@ def _triage_db(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_newest_first_orders_by_recency(tmp_path, monkeypatch):
|
||||
html = _triage_db(tmp_path, monkeypatch).get("/?sort=new&show=all").text
|
||||
# window=48h, not the default: c_old is 30 hours back and the default
|
||||
# window is 24, by design.
|
||||
html = _triage_db(tmp_path, monkeypatch).get("/?sort=new&show=all&window=48h").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"
|
||||
@@ -478,15 +480,17 @@ def test_the_triage_queue_ages_stories_out(tmp_path, monkeypatch):
|
||||
web._VECTORS = None
|
||||
c = TestClient(web.app)
|
||||
|
||||
# Default window is 48h, matching the routing staleness rule: nothing older
|
||||
# can be notified, so nothing older needs triaging either.
|
||||
# The default window matches the routing staleness rule -- nothing older can
|
||||
# be notified, so nothing older needs triaging -- and both are 24h, the
|
||||
# daily cadence the app is built on.
|
||||
html = c.get("/?show=all").text
|
||||
assert "Story fresh" in html
|
||||
assert "Story yesterday" in html
|
||||
assert "Story yesterday" not in html, "a 30-hour-old story is outside a day"
|
||||
assert "Story lastweek" not in html, "a five-day-old story is still in the queue"
|
||||
assert "Story backfill" not in html, "feed backfill is still in the queue"
|
||||
|
||||
assert "Story yesterday" not in c.get("/?show=all&window=24h").text
|
||||
# Widening is one click, for catching up after a few days away.
|
||||
assert "Story yesterday" in c.get("/?show=all&window=48h").text
|
||||
assert "Story lastweek" in c.get("/?show=all&window=7d").text
|
||||
assert "Story backfill" in c.get("/?show=all&window=all").text
|
||||
# A bad value must fall back, not 500 and not silently widen to everything.
|
||||
|
||||
Reference in new issue
Block a user