Scoring (news/score.py): every profile block is embedded and compared by
z-score WITHIN block, then weighted. Veto fires on absolute similarity OR a
margin over the interest signal; demotes apply a penalty instead of excluding,
because those categories sit adjacent to real interests.
Importance is deliberately NOT corroboration count -- the old system's error,
which measured the subscription list rather than the news. It combines block
similarity, corroboration, burst velocity, editorial prominence and aggregator
score, all weighted from config.
Routing (news/route.py): two lanes with separate budgets. Interest never
overrides quiet hours; importance may, within a hard nightly cap. Vetoes are
absolute on BOTH lanes -- the v0.2 bypass lane never consulted relevance, so a
blocklisted item could still fire at priority 5, and a test now asserts it
cannot. Budgets are spent strongest-first rather than first-come.
Measured on 1,318 real clusters: 2.5 notifications/day and a 14-item digest,
inside the 2-5/day target.
Three defects the real data exposed, none of which unit tests would have caught:
- velocity was computed as origins/hours with a 0.5h floor, giving every
SINGLE-source cluster a spurious velocity of 2.0. Growth rate is undefined
for a cluster that has not grown; it is now zero below two origins.
- deferred items (quiet hours, budget exhausted, immature) settled straight
into the digest and bypassed its cap, producing 198 items/day. Everything
that misses an alert lane now competes for one ranked, capped digest.
- feeds return large archives on first fetch -- 88 Strong Towns items going back
five months, and a BBC entry titled "BBC News app" dated 2025. Those belong in
the scoring population but must never notify, so routing now has a recency
requirement.
Reading the output also found two profile gaps: recurring columns and link
round-ups ("Catching Our Eye News Roundup", "Astronomy Picture of the Day")
scored as top-ranked interest items. They are on-topic but publish on a
schedule, so they are demoted rather than vetoed and still reach the digest.
221 lines
8.4 KiB
Python
221 lines
8.4 KiB
Python
"""Routing: turn scores into decisions.
|
|
|
|
Two independent lanes, because "something I might like" and "something I need to
|
|
know" are different questions:
|
|
|
|
alert:interest high interest. Respects quiet hours, always.
|
|
alert:important high importance regardless of interest. May wake the
|
|
reader, with its own smaller budget and a hard nightly cap.
|
|
digest everything else worth reading, collected for the morning.
|
|
drop vetoed, or below the floor.
|
|
|
|
Vetoes are absolute on BOTH lanes. In the old system the bypass lane never
|
|
consulted relevance (gate.rs:302), so a blocklisted item could still fire at
|
|
priority 5. That must not recur, and a test asserts it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, time, timedelta
|
|
|
|
from .score import ClusterScore
|
|
|
|
INTEREST = "alert:interest"
|
|
IMPORTANT = "alert:important"
|
|
DIGEST = "digest"
|
|
DROP = "drop"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Decision:
|
|
cluster_id: str
|
|
lane: str
|
|
reason: str
|
|
score: float
|
|
|
|
|
|
def _parse_hhmm(value: str) -> time:
|
|
hh, mm = value.split(":")
|
|
return time(int(hh), int(mm))
|
|
|
|
|
|
def in_quiet_hours(now: datetime, quiet: dict) -> bool:
|
|
start = _parse_hhmm(quiet.get("start", "22:00"))
|
|
end = _parse_hhmm(quiet.get("end", "07:00"))
|
|
minute = now.hour * 60 + now.minute
|
|
s, e = start.hour * 60 + start.minute, end.hour * 60 + end.minute
|
|
# Quiet hours normally wrap midnight.
|
|
return minute >= s or minute < e if s > e else s <= minute < e
|
|
|
|
|
|
def _sent_since(con: sqlite3.Connection, lane: str, since: datetime) -> int:
|
|
row = con.execute(
|
|
"SELECT COUNT(*) AS n FROM notification_log WHERE lane=? AND created_at>=?",
|
|
(lane, since.isoformat()),
|
|
).fetchone()
|
|
return int(row["n"] or 0)
|
|
|
|
|
|
def route(
|
|
con: sqlite3.Connection,
|
|
scores: list[ClusterScore],
|
|
routing: dict,
|
|
delivery: dict,
|
|
now: datetime,
|
|
) -> list[Decision]:
|
|
"""Decide a lane for every scored cluster.
|
|
|
|
Clusters are considered strongest-first within each lane, so a limited daily
|
|
budget is spent on the best candidates rather than on whatever arrived
|
|
first -- the ordering bug that let the old system's budget go to noise.
|
|
"""
|
|
quiet = delivery.get("quiet_hours", {})
|
|
quiet_now = in_quiet_hours(now, quiet)
|
|
may_wake = bool(delivery.get("importance_may_wake", True))
|
|
day_ago = now - timedelta(days=1)
|
|
|
|
interest_budget = routing.get("interest_per_day", 3) - _sent_since(con, INTEREST, day_ago)
|
|
important_budget = routing.get("important_per_day", 2) - _sent_since(con, IMPORTANT, day_ago)
|
|
night_budget = routing.get("important_per_night", 1) - _sent_since(
|
|
con, IMPORTANT, now - timedelta(hours=10))
|
|
|
|
already = {
|
|
r["cluster_id"]
|
|
for r in con.execute(
|
|
"SELECT DISTINCT cluster_id FROM notification_log WHERE created_at>=?",
|
|
((now - timedelta(days=3)).isoformat(),),
|
|
)
|
|
}
|
|
|
|
decisions: dict[str, Decision] = {}
|
|
# Deferred items are digest CANDIDATES, not digest members. Everything that
|
|
# misses an alert lane competes for the same capped digest, so a quiet-hours
|
|
# deferral cannot smuggle 190 items past the cap the way an earlier version
|
|
# did.
|
|
deferred: dict[str, str] = {}
|
|
|
|
def settle(s: ClusterScore, lane: str, reason: str, value: float) -> None:
|
|
decisions[s.cluster_id] = Decision(s.cluster_id, lane, reason, value)
|
|
|
|
def defer(s: ClusterScore, reason: str) -> None:
|
|
deferred[s.cluster_id] = reason
|
|
|
|
max_age = timedelta(hours=routing.get("max_age_hours", 48))
|
|
live: list[ClusterScore] = []
|
|
for s in scores:
|
|
# Staleness first. Feeds hand back large archives on first fetch -- one
|
|
# run pulled 88 items going back five months, plus a BBC entry literally
|
|
# titled "BBC News app" dated 2025. Those belong in the scoring corpus,
|
|
# because the z-score population should reflect what news looks like,
|
|
# but nothing that old may reach the reader.
|
|
if s.last_seen is not None and now - s.last_seen > max_age:
|
|
settle(s, DROP, "stale", s.interest_score)
|
|
elif s.veto:
|
|
settle(s, DROP, f"veto:{s.veto}", s.interest_score)
|
|
elif s.cluster_id in already:
|
|
settle(s, DROP, "already-notified", s.interest_score)
|
|
elif _immature(s, routing):
|
|
# Not rejected, just not ripe: the crowd has not voted yet. It is
|
|
# re-scored on the next cycle as its score climbs.
|
|
settle(s, DROP, "aggregator-immature", s.interest_score)
|
|
else:
|
|
live.append(s)
|
|
|
|
# --- importance lane first: smaller budget, higher stakes ---
|
|
for s in sorted(live, key=lambda x: -x.importance_score):
|
|
if s.cluster_id in decisions or s.cluster_id in deferred:
|
|
continue
|
|
if s.importance_score < routing.get("important_threshold", 2.0):
|
|
continue
|
|
if important_budget <= 0:
|
|
defer(s, "important-budget-exhausted")
|
|
continue
|
|
if quiet_now:
|
|
if not may_wake or night_budget <= 0:
|
|
defer(s, "quiet-hours")
|
|
continue
|
|
night_budget -= 1
|
|
important_budget -= 1
|
|
settle(s, IMPORTANT, f"importance:{s.importance.block}", s.importance_score)
|
|
|
|
# --- interest lane: never overrides quiet hours ---
|
|
for s in sorted(live, key=lambda x: -x.interest_score):
|
|
if s.cluster_id in decisions or s.cluster_id in deferred:
|
|
continue
|
|
if s.interest_score < routing.get("interest_threshold", 2.0):
|
|
continue
|
|
if _conflict_needs_importance(s, routing, delivery):
|
|
defer(s, "conflict-general-needs-importance")
|
|
continue
|
|
if quiet_now:
|
|
defer(s, "quiet-hours")
|
|
continue
|
|
if interest_budget <= 0:
|
|
defer(s, "interest-budget-exhausted")
|
|
continue
|
|
interest_budget -= 1
|
|
settle(s, INTEREST, f"interest:{s.interest.block}", s.interest_score)
|
|
|
|
# --- one capped, ranked digest ---
|
|
floor = routing.get("digest_floor", 0.0)
|
|
cap = routing.get("digest_max_items", 25)
|
|
pool = sorted(
|
|
(s for s in live if s.cluster_id not in decisions),
|
|
key=lambda x: -max(x.interest_score, x.importance_score),
|
|
)
|
|
taken = 0
|
|
for s in pool:
|
|
value = max(s.interest_score, s.importance_score)
|
|
reason = deferred.get(s.cluster_id, "digest")
|
|
if value < floor and s.cluster_id not in deferred:
|
|
settle(s, DROP, "below-floor", value)
|
|
elif taken < cap:
|
|
settle(s, DIGEST, reason, value)
|
|
taken += 1
|
|
else:
|
|
settle(s, DROP, "digest-full", value)
|
|
|
|
# Serendipity: reserve slots for items that scored well on importance but
|
|
# POORLY on the interest profile, so the digest does not become a mirror of
|
|
# the profile. A preference learner tightens its own bubble as it succeeds.
|
|
slots = int(delivery.get("serendipity_slots", 0))
|
|
if slots:
|
|
ceiling = routing.get("serendipity_max_interest", 0.5)
|
|
outside = sorted(
|
|
(s for s in pool
|
|
if decisions[s.cluster_id].lane == DROP and s.interest_score < ceiling),
|
|
key=lambda x: -x.importance_score,
|
|
)
|
|
for s in outside[:slots]:
|
|
settle(s, DIGEST, "serendipity", s.importance_score)
|
|
|
|
return [decisions[s.cluster_id] for s in scores]
|
|
|
|
|
|
def _immature(s: ClusterScore, routing: dict) -> bool:
|
|
"""Aggregator items wait for the crowd.
|
|
|
|
An HN post at 3 points is noise and the same post at 300 is news, so an item
|
|
whose only signal is an aggregator score below the bar is held for the
|
|
digest rather than routed on arrival.
|
|
"""
|
|
threshold = routing.get("hn_min_score", 150)
|
|
return s.ext_score is not None and s.ext_score < threshold and s.corroboration.origins <= 1
|
|
|
|
|
|
def _conflict_needs_importance(s: ClusterScore, routing: dict, delivery: dict) -> bool:
|
|
"""Category-E conflict notifies only on a major development.
|
|
|
|
Connor: "my actual war interest is low, but unfortunately all wars are
|
|
important." Routine front-line reporting goes to the digest; a new
|
|
offensive, a ceasefire or mass casualties clears the importance bar and
|
|
reaches him through the other lane.
|
|
"""
|
|
if not delivery.get("conflict_general_requires_importance", True):
|
|
return False
|
|
if s.interest.block != "conflict-general":
|
|
return False
|
|
return s.importance_score < routing.get("important_threshold", 2.0)
|