Make the system portable: split config from profile, remove hard-coding
Audit found three things compiled in that shouldn't have been: the User-Agent carried the author's domain, the cluster threshold and window were module constants the pipeline never overrode, and the embedding model was pinned -- which blocked the one change most likely to be wanted, swapping in a multilingual model for native-language sources. Configuration is now three files with distinct roles: config.toml (machinery), profile.toml (who the reader is), sources.toml (what to read). All are relocatable via NEWS_CONFIG_DIR. news/*.py now contains no interest, place name, feed URL, or personal identifier. tests/test_config.py keeps it that way: it builds a configuration for an invented reader and asserts none of the author's identity or preferences reaches the running code. Writing it immediately found a real bug -- CONFIG_DIR was resolved at import time, so NEWS_CONFIG_DIR silently did nothing if set after the module was first imported. Now resolved per instantiation. Documented the coupling that portability creates: [clustering].threshold and [scoring].veto_absolute are cosine constants calibrated against one corpus with one model, so changing [embedding].model invalidates both.
This commit is contained in:
1 parent
6dc3694260
commit
863f5e20ac
8 files changed
+317
-36
No files matched your search
@@ -0,0 +1,128 @@
|
||||
"""Configuration must actually drive behaviour.
|
||||
|
||||
The point of these tests is portability: someone who is not the author should be
|
||||
able to run this by replacing the TOML files, without editing Python. Each test
|
||||
below asserts that a value in config reaches the code that uses it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from news import cluster, store
|
||||
from news.config import Config
|
||||
from news.embed import Embedder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfgdir(tmp_path, monkeypatch):
|
||||
(tmp_path / "config.toml").write_text(textwrap.dedent("""
|
||||
[http]
|
||||
user_agent = "someone-else/9.9 (+https://elsewhere.example)"
|
||||
timeout_secs = 7
|
||||
concurrency = 3
|
||||
[embedding]
|
||||
model = "intfloat/multilingual-e5-small"
|
||||
dim = 512
|
||||
[clustering]
|
||||
threshold = 0.5
|
||||
window_hours = 6
|
||||
[scoring]
|
||||
veto_absolute = 0.99
|
||||
"""))
|
||||
(tmp_path / "sources.toml").write_text(textwrap.dedent("""
|
||||
[defaults]
|
||||
poll_interval_secs = 111
|
||||
[[source]]
|
||||
name = "Someone Else's Feed"
|
||||
url = "https://elsewhere.example/rss"
|
||||
bucket = "elsewhere"
|
||||
"""))
|
||||
(tmp_path / "profile.toml").write_text(textwrap.dedent("""
|
||||
[[interest]]
|
||||
id = "trains"
|
||||
weight = 0.4
|
||||
probes = ["Rolling stock procurement and timetable changes."]
|
||||
[delivery]
|
||||
digest_hour = "19:00"
|
||||
"""))
|
||||
monkeypatch.setenv("NEWS_CONFIG_DIR", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_no_authors_identity_leaks_into_a_fresh_config(cfgdir):
|
||||
cfg = Config()
|
||||
assert "rcjohnstone" not in cfg.user_agent
|
||||
assert cfg.user_agent.startswith("someone-else/9.9")
|
||||
|
||||
|
||||
def test_machinery_values_are_read_from_config(cfgdir):
|
||||
cfg = Config()
|
||||
assert cfg.http["timeout_secs"] == 7
|
||||
assert cfg.http["concurrency"] == 3
|
||||
assert cfg.embedding == {"model": "intfloat/multilingual-e5-small", "dim": 512}
|
||||
assert cfg.clustering == {"threshold": 0.5, "window_hours": 6}
|
||||
assert cfg.scoring["veto_absolute"] == 0.99
|
||||
|
||||
|
||||
def test_embedder_takes_its_model_from_config_without_loading_it(cfgdir):
|
||||
"""A different reader can swap in a multilingual model from TOML alone."""
|
||||
emb = Embedder.from_config(Config())
|
||||
assert emb._model_name == "intfloat/multilingual-e5-small"
|
||||
assert emb.encode([]).shape == (0, 512) # honours the configured dim
|
||||
|
||||
|
||||
def test_reader_profile_is_entirely_data(cfgdir):
|
||||
cfg = Config()
|
||||
profile = cfg.profile()
|
||||
ids = [b["id"] for b in profile["interest"]]
|
||||
assert ids == ["trains"], "profile content must come only from TOML"
|
||||
assert cfg.delivery["digest_hour"] == "19:00"
|
||||
|
||||
|
||||
def test_sources_come_from_config_with_defaults_applied(cfgdir):
|
||||
srcs = Config().sources()
|
||||
assert len(srcs) == 1
|
||||
assert srcs[0]["name"] == "Someone Else's Feed"
|
||||
assert srcs[0]["poll_interval_secs"] == 111 # from [defaults]
|
||||
assert srcs[0]["kind"] == "rss" and srcs[0]["weight"] == 1.0
|
||||
|
||||
|
||||
def test_configured_cluster_threshold_changes_the_outcome(tmp_path):
|
||||
"""The calibrated 0.82 is a default, not a law: config must override it."""
|
||||
con = store.connect(tmp_path / "t.db")
|
||||
store.upsert_source(con, id="s1", name="A", url="http://a", kind="rss",
|
||||
bucket="a", weight=1.0, prominence=0, enabled=1,
|
||||
poll_interval_secs=900)
|
||||
|
||||
def add(i, when):
|
||||
con.execute(
|
||||
"""INSERT INTO items (id,source_id,title,summary,url,canonical_url,
|
||||
published_at,first_seen_at,content_hash)
|
||||
VALUES (?,?,?,'',?,?,?,?,?)""",
|
||||
(i, "s1", i, f"http://x/{i}", f"http://x/{i}", when, when, i))
|
||||
return con.execute("SELECT * FROM items WHERE id=?", (i,)).fetchone()
|
||||
|
||||
rows = [add("i1", "2026-01-01T00:00:00+00:00"), add("i2", "2026-01-01T01:00:00+00:00")]
|
||||
a, b = np.array([1, 0], np.float32), np.array([0.6, 0.8], np.float32) # cosine 0.6
|
||||
vecs = {"i1": a, "i2": b}
|
||||
|
||||
strict = cluster.assign(con, rows, vecs, threshold=0.9)
|
||||
assert strict[0].cluster_id != strict[1].cluster_id
|
||||
|
||||
con2 = store.connect(tmp_path / "t2.db")
|
||||
store.upsert_source(con2, id="s1", name="A", url="http://a", kind="rss",
|
||||
bucket="a", weight=1.0, prominence=0, enabled=1,
|
||||
poll_interval_secs=900)
|
||||
for i, when in (("i1", "2026-01-01T00:00:00+00:00"), ("i2", "2026-01-01T01:00:00+00:00")):
|
||||
con2.execute(
|
||||
"""INSERT INTO items (id,source_id,title,summary,url,canonical_url,
|
||||
published_at,first_seen_at,content_hash)
|
||||
VALUES (?,?,?,'',?,?,?,?,?)""",
|
||||
(i, "s1", i, f"http://x/{i}", f"http://x/{i}", when, when, i))
|
||||
rows2 = con2.execute("SELECT * FROM items ORDER BY published_at").fetchall()
|
||||
loose = cluster.assign(con2, list(rows2), vecs, threshold=0.5)
|
||||
assert loose[0].cluster_id == loose[1].cluster_id
|
||||
Reference in new issue
Block a user