Files
news/news/config.py
T
connor a001a09fdf Add Dockerfile and deployment for replacing news-backend in place
Two-stage build. The builder bakes the embedding model into the image (~65 MB),
so a cold container needs no outbound access to Hugging Face and starts scoring
in 0.4s -- verified with HF_HUB_OFFLINE=1.

Three things the smoke test caught that would each have failed in production:

- Debian's base image already ships a system account named "news", so useradd
  exits 9 on the collision. The runtime user is "app".
- The image runs as uid 10001 rather than root, so a host-owned volume gives
  SQLite "attempt to write a readonly database". Compose needs the `U` mount
  flag.
- The baked model cache was root-owned, making fastembed log a permission
  warning and discard its tree cache on every start.

Deployment targets the EXISTING service rather than a parallel one: same vhost,
and NTFY_TOPIC=news-triage because the old service defaulted to that rather than
setting it, so the phone subscription keeps working.

The Caddy change is the consequential one and is documented as such: the vhost
moves from LAN-only to public, with /api/feedback/* bypassing Authelia because
ntfy runs action buttons from the phone and a native app cannot complete an
interactive login -- the same exemption ntfy, Immich and Jellyfin already have
on this box. That path is HMAC-signed per (cluster, kind).
2026-09-08 16:38:10 -04:00

156 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),
"cache_dir": e.get("cache_dir"),
}
@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", {})