diff --git a/news/config.py b/news/config.py index fe00472..eaa9f21 100644 --- a/news/config.py +++ b/news/config.py @@ -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), diff --git a/news/route.py b/news/route.py index 2a728a2..03d0e6c 100644 --- a/news/route.py +++ b/news/route.py @@ -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 diff --git a/profile/config.toml b/profile/config.toml index 0a87a0a..f0947f2 100644 --- a/profile/config.toml +++ b/profile/config.toml @@ -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. diff --git a/tests/test_delivery.py b/tests/test_delivery.py index fd95694..395bf5c 100644 --- a/tests/test_delivery.py +++ b/tests/test_delivery.py @@ -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.