Files
news/profile/config.toml
T
connor dc6b6fea7e Phase 4: delivery -- ntfy, working feedback buttons, digest page
ntfy push is deliberately short: a title, one line, and a Click through to the
story. The digest is a WEB PAGE, not a push payload -- the old system sent the
whole report as one long markdown message and ntfy turned it into a .md
attachment, which is what made it miserable to read on a phone.

Feedback buttons finally work. The old ones never fired once: NEWS_API_BASE_URL
was unset so they were dropped behind a single boot WARN, and /api/feedback had
no auth anyway. ntfy runs http actions FROM THE PHONE, so the endpoint must be
public -- which is why each button now carries an HMAC signed over
(cluster_id, kind). A leaked URL authorises one vote on one story rather than
acting as a bearer token for the whole model. Missing config drops the buttons
loudly instead of silently.

Digest prose is one LLM call per day through LiteLLM, and that is the entire
generative-model surface of this system -- clustering, relevance, importance and
vetoes are all embeddings and arithmetic. Feed text is passed as JSON data,
never interpolated into prompt prose, so a headline cannot read as an
instruction. Any failure falls back to the plain list; the digest never depends
on a model being reachable. Verified against the live proxy: 20s, themed
paragraphs, no invented facts.

Digest page is mobile-first, dark-mode aware, and renders from notification_log
so it is a record of what was actually sent.

deploy/README.md documents the Caddy vhost and env the buttons require, and uses
a python healthcheck rather than curl -- the v0.2 image shipped no curl, so its
healthcheck failed forever while the service was fine.

62 tests.
2026-09-08 16:27:27 -04:00

135 lines
5.9 KiB
TOML

# Runtime configuration and tuning knobs.
#
# Three-file split, so a new user knows what to touch:
# config.toml this file -- machinery. Sane defaults; rarely edited.
# profile.toml who you are: interests, vetoes, importance, delivery.
# sources.toml what to read.
#
# Nothing here is specific to any person. Everything personal lives in the
# other two files.
[http]
# Sent on every outbound request. Feed operators use it to identify traffic and
# some will block a generic or absent agent, so put a real contact here.
# CHANGE THIS if you are not Connor -- it is the contact address feed operators
# see, and pointing them at someone else's domain is rude and unhelpful.
user_agent = "news/0.1 (+https://news.rcjohnstone.com; personal feed reader; one user)"
timeout_secs = 30
connect_timeout_secs = 10
concurrency = 8
max_body_bytes = 10485760
[embedding]
# Any model fastembed can serve. `dim` must match the model.
#
# The default is English-only. For native-language sources use a multilingual
# model -- "intfloat/multilingual-e5-small" (384 dim) is the usual choice.
# Be aware that changing the model INVALIDATES the calibrated constants below
# and in [scoring]: they are cosine thresholds, and a different model puts
# similarity on a different scale. Re-run the calibration before trusting them.
model = "BAAI/bge-small-en-v1.5"
dim = 384
[clustering]
# Calibrated by inspection on a 3,885-item corpus; see CALIBRATION.md.
# 0.70 merged "Solheim Cup capsules" with "FIBA Women's World Cup Glance";
# 0.86 under-merged. Over-merging is the worse failure because it inflates the
# corroboration signal that feeds importance.
#
# Re-tune if your sources differ substantially in style or language.
threshold = 0.82
window_hours = 48
# Above this cosine, two items from DIFFERENT newsrooms are treated as the same
# syndicated copy rather than independent corroboration. Deliberately high:
# eight outlets each writing their own Renoir-heist headline stayed separate,
# while a local station reprinting a wire dispatch collapsed correctly.
syndication_threshold = 0.97
[scoring]
# Compare blocks by z-score WITHIN each block, never by raw cosine: cosine
# magnitude is not comparable across blocks, and a plain max let one block win
# 78% of all items. See CALIBRATION.md.
# "zscore-per-block" or "top-anchored". Both normalise per GROUP (see `group`
# in profile.toml), not per block: taking the max across blocks gives a subject
# one draw per block, so a topic split into five blocks beats one covered by a
# single block purely by having more chances.
#
# NOTE the thresholds in [routing] are in z-score units. "top-anchored" puts
# scores on a different scale (1.0 = that group's 99th percentile), so switching
# normalizer REQUIRES re-tuning them -- verified: leaving them at 2.0 silences
# the interest lane completely.
normalize = "zscore-per-block"
# Veto is not a plain similarity threshold. A story can be genuinely adjacent to
# a vetoed topic and still be wanted, so an item is vetoed on unambiguous
# similarity OR when the veto signal beats the best interest signal by a margin.
veto_absolute = 0.60
veto_margin_over_interest = 0.3
# Corroboration = origins + pickup_weight*log1p(pickups) + depth_weight*log1p(depth).
# "Several outlets carried this" covers four different situations; see
# news/cluster.py:Corroboration. Origins (independent newsrooms doing their own
# reporting) dominate; the other two break ties among the many two- and
# three-origin clusters. Measured on a real corpus: 163 origins, 90 depth,
# 11 pickups -- depth matters far more here because these sources mostly write
# their own copy. Raise pickup_weight if you add local papers running wire.
corroboration_pickup_weight = 0.3
corroboration_depth_weight = 0.2
[routing]
# Daily budgets. Deliberately small: the point of the system is a handful of
# well-chosen interruptions, not a feed.
interest_per_day = 3
# Max alerts per quota group per day (see `group` in profile.toml). Stops one
# subject taking every slot.
max_alerts_per_group_per_day = 1
important_per_day = 2
# Hard cap on waking the reader during quiet hours, inside important_per_day.
important_per_night = 1
# Thresholds are in z-score units -- "how many standard deviations above normal
# for this block", not raw cosine. Calibrate with `news simulate`, which reports
# the notifications-per-day these values would actually have produced.
interest_threshold = 2.0
important_threshold = 2.0
digest_floor = 0.5
# A floor alone yielded 198 items a day. The digest is capped by count and fed
# strongest-first, so the cap is always spent on the best candidates.
digest_max_items = 25
# Serendipity slots are filled from items BELOW this interest score, so they are
# genuinely outside the profile rather than near-misses.
serendipity_max_interest = 0.5
# Aggregator maturation: an HN post at 3 points is noise, at 300 it is news.
hn_min_score = 150
# 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
[scoring.importance_weights]
# Importance is NOT corroboration count -- that was the old system's error.
# Velocity is weighted as heavily as corroboration: 0 to 6 independent origins
# in ninety minutes is what breaking news looks like, and a story sitting at 6
# origins for three days is not.
block = 1.0
corroboration = 0.4
velocity = 0.5
prominence = 0.3
aggregator = 0.3
[llm]
# The ENTIRE generative-model surface of this system: one call per day to write
# the digest prose. Clustering, relevance, importance and vetoes are all
# embeddings and arithmetic -- no model in the scoring path.
#
# Cost on this alias is pinned to 0.0 in the LiteLLM config, so a NONZERO spend
# row is the deliberate quota-overflow alarm, not a billing surprise.
enabled = true
base_url = "http://litellm:4000"
model = "news/glm-5.3-flash"
api_key_env = "NEWS_LITELLM_KEY"
timeout_secs = 60