Connor noticed 5 of 5 alerts were Middle East. Measuring showed that was not proportionate: Middle East blocks are ~11% of the eligible corpus while software-systems alone is 25%, yet Middle East took 35% of the top 20 and software took none. Two mechanisms, both measured: 1. Five conflict-* blocks could each claim their own alert slot. Interest blocks now carry a `group`, and routing enforces max_alerts_per_group_per_day, so related blocks share one quota. This alone moved the mix from 5/5 Middle East to three distinct groups. 2. Z-scoring subtracts each block's MEAN, which silently encodes how much of the corpus that block matches. software-systems has the HIGHEST mean raw similarity of any block (0.537) and the LOWEST max z (2.51); conflict-mideast-israel has a lower mean (0.460) and reaches 3.47. A topic is penalised for being well covered in your own feed list. Added `normalize = "top-anchored"` as an alternative that rescales each block by its own upper tail instead of its mean, leaving `weight` to express preference rather than corpus composition. Also ruled out a plausible-sounding cause rather than assuming it: HN and Lobsters items are title-only and much shorter than news articles, so terse titles seemed likely to match prose probes poorly. Measured the opposite -- title-only HN items score 0.618 against the software block, ABOVE Phoronix/LWN with summaries (0.610) and Ars Technica (0.540). Short text is not the problem; prevalence is.
152 lines
5.7 KiB
Python
152 lines
5.7 KiB
Python
"""Configuration loading.
|
|
|
|
Three files, with a deliberate split so a new user knows what to edit:
|
|
|
|
config.toml machinery: HTTP, embedding model, clustering and scoring
|
|
constants. Nothing personal. Sane defaults.
|
|
profile.toml who the reader is: interests, vetoes, demotes, importance,
|
|
delivery preferences.
|
|
sources.toml what to read.
|
|
|
|
Every path is overridable by environment variable so the same code can run
|
|
against a different reader's configuration without editing anything.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tomllib
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def config_dir() -> Path:
|
|
"""Resolved per call, not at import.
|
|
|
|
Reading the environment at module scope meant NEWS_CONFIG_DIR was captured
|
|
once when `news.config` was first imported, so setting it afterwards -- in a
|
|
test, a subprocess, or anything that configures its environment after
|
|
importing -- silently did nothing.
|
|
"""
|
|
return Path(os.environ.get("NEWS_CONFIG_DIR", REPO / "profile"))
|
|
|
|
|
|
def _path(env: str, default: Path) -> Path:
|
|
return Path(os.environ.get(env, default))
|
|
|
|
|
|
def _load(path: Path) -> dict[str, Any]:
|
|
return tomllib.loads(path.read_text()) if path.exists() else {}
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
db_path: Path = field(default_factory=lambda: _path("NEWS_DB", REPO / "data" / "news.db"))
|
|
config_path: Path = field(
|
|
default_factory=lambda: _path("NEWS_CONFIG", config_dir() / "config.toml"))
|
|
sources_path: Path = field(
|
|
default_factory=lambda: _path("NEWS_SOURCES", config_dir() / "sources.toml"))
|
|
profile_path: Path = field(
|
|
default_factory=lambda: _path("NEWS_PROFILE", config_dir() / "profile.toml"))
|
|
|
|
def __post_init__(self) -> None:
|
|
self._raw = _load(self.config_path)
|
|
|
|
# -- machinery ---------------------------------------------------------
|
|
def section(self, name: str) -> dict[str, Any]:
|
|
return self._raw.get(name, {})
|
|
|
|
@property
|
|
def user_agent(self) -> str:
|
|
return self.section("http").get(
|
|
"user_agent", "news/0.1 (+https://example.invalid; personal feed reader)")
|
|
|
|
@property
|
|
def http(self) -> dict[str, Any]:
|
|
h = self.section("http")
|
|
return {
|
|
"timeout_secs": h.get("timeout_secs", 30),
|
|
"connect_timeout_secs": h.get("connect_timeout_secs", 10),
|
|
"concurrency": h.get("concurrency", 8),
|
|
"max_body_bytes": h.get("max_body_bytes", 10 * 1024 * 1024),
|
|
}
|
|
|
|
@property
|
|
def embedding(self) -> dict[str, Any]:
|
|
e = self.section("embedding")
|
|
return {"model": e.get("model", "BAAI/bge-small-en-v1.5"), "dim": e.get("dim", 384)}
|
|
|
|
@property
|
|
def clustering(self) -> dict[str, Any]:
|
|
c = self.section("clustering")
|
|
return {
|
|
"threshold": c.get("threshold", 0.82),
|
|
"window_hours": c.get("window_hours", 48),
|
|
"syndication_threshold": c.get("syndication_threshold", 0.97),
|
|
}
|
|
|
|
@property
|
|
def scoring(self) -> dict[str, Any]:
|
|
s = self.section("scoring")
|
|
return {
|
|
"normalize": s.get("normalize", "zscore-per-block"),
|
|
"veto_absolute": s.get("veto_absolute", 0.60),
|
|
"veto_margin_over_interest": s.get("veto_margin_over_interest", 0.3),
|
|
"corroboration_pickup_weight": s.get("corroboration_pickup_weight", 0.3),
|
|
"corroboration_depth_weight": s.get("corroboration_depth_weight", 0.2),
|
|
"demote_absolute": s.get("demote_absolute", 0.55),
|
|
"importance_weights": s.get("importance_weights", {}),
|
|
}
|
|
|
|
@property
|
|
def routing(self) -> dict[str, Any]:
|
|
r = self.section("routing")
|
|
return {
|
|
"interest_per_day": r.get("interest_per_day", 3),
|
|
"max_alerts_per_group_per_day": r.get("max_alerts_per_group_per_day", 1),
|
|
"important_per_day": r.get("important_per_day", 2),
|
|
"important_per_night": r.get("important_per_night", 1),
|
|
"interest_threshold": r.get("interest_threshold", 2.0),
|
|
"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),
|
|
"digest_max_items": r.get("digest_max_items", 25),
|
|
"serendipity_max_interest": r.get("serendipity_max_interest", 0.5),
|
|
}
|
|
|
|
# -- reader ------------------------------------------------------------
|
|
def sources(self) -> list[dict]:
|
|
raw = _load(self.sources_path)
|
|
defaults = raw.get("defaults", {})
|
|
out = []
|
|
for entry in raw.get("source", []):
|
|
merged = {**defaults, **entry}
|
|
merged.setdefault("kind", "rss")
|
|
merged.setdefault("bucket", merged["name"].lower().replace(" ", "-"))
|
|
merged.setdefault("weight", 1.0)
|
|
merged.setdefault("prominence", 0)
|
|
merged.setdefault("enabled", True)
|
|
merged.setdefault("poll_interval_secs", 900)
|
|
out.append(merged)
|
|
return out
|
|
|
|
def profile(self) -> dict[str, Any]:
|
|
return _load(self.profile_path)
|
|
|
|
def block_groups(self) -> dict[str, str]:
|
|
"""block id -> quota group, defaulting to the block's own id."""
|
|
profile = self.profile()
|
|
out: dict[str, str] = {}
|
|
for kind in ("interest", "importance"):
|
|
for b in profile.get(kind, []):
|
|
out[b["id"]] = b.get("group", b["id"])
|
|
return out
|
|
|
|
@property
|
|
def delivery(self) -> dict[str, Any]:
|
|
return self.profile().get("delivery", {})
|