Antispam: plugin docs, the learning acceptance test, and a bootstrap that refuses bad corpora
docs/plugins.md described an SDK that no longer exists. It now covers messages.raw/sample, actions.move_to_special, the envelope's account_id / mailbox_special_use / account_state, the message.changed transition payload, the message.actions.menu slot, Slot.title and .wm-menu-item — with the reasons, not just the signatures. M3's acceptance is that a plugin can be written from this file alone, and it had drifted. Acceptance 4 (§C.8): a test that teaches 20 spam + 20 ham to a real engine and checks the classifier generalises to held-out mail, keeps ham under the banner threshold, and does not leak between users. It skips when no engine is reachable. It does NOT assert the literal move_threshold — a synthetic fixture cannot reach 12.0 once the SMTP-time symbols are zeroed, which is the whole point of the tuning. Recorded as OQ-8 rather than quietly asserting something weaker. Bayes ignores training until min_learns, stock 200. For a personal mailbox that means every correction appears to be ignored for months, so it is now 20. The bootstrap would have made this filter worse. Accounts do not sync their provider's spam folder by default, so the real corpus here is 3 junk beside 1,294 aged inbox — teaching both as found trains "everything is ham". It now refuses below 10 spam samples (leaving the account unmarked so a later run can do it properly) and otherwise caps ham at 3x the spam count. Run against the live account, it correctly declined: "only 3 spam samples (need 10); enable spam-folder sync to build a corpus" Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -188,3 +188,95 @@ async def test_bootstrap_respects_the_setting(monkeypatch: pytest.MonkeyPatch) -
|
||||
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
||||
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
|
||||
assert sampled == []
|
||||
|
||||
|
||||
async def test_bootstrap_refuses_a_lopsided_corpus(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Three spam beside a thousand ham would teach "everything is ham".
|
||||
|
||||
Accounts do not sync the provider's spam folder by default, so this is the
|
||||
common case, not an edge case.
|
||||
"""
|
||||
taught: list[bool] = []
|
||||
|
||||
async def _settings(_u: object) -> policy.Settings:
|
||||
return SETTINGS
|
||||
|
||||
class _Kv:
|
||||
async def get(self, _k: str, _d: object = None) -> list[str]:
|
||||
return []
|
||||
|
||||
async def set(self, _k: str, _v: object) -> None:
|
||||
raise AssertionError("must not mark the account done")
|
||||
|
||||
class _M:
|
||||
async def sample(self, *_a: object, **kw: object) -> list[uuid.UUID]:
|
||||
n = 3 if kw.get("special_use") == "junk" else 1000
|
||||
return [uuid.uuid4() for _ in range(n)]
|
||||
|
||||
async def raw(self, *_a: object, **_k: object) -> bytes:
|
||||
return b"x"
|
||||
|
||||
class _Fake:
|
||||
messages = _M()
|
||||
|
||||
def kv(self, _u: object) -> _Kv:
|
||||
return _Kv()
|
||||
|
||||
async def _learn(_raw: bytes, **kw: object) -> bool:
|
||||
taught.append(bool(kw.get("spam")))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(policy, "load_settings", _settings)
|
||||
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
||||
monkeypatch.setattr(jobs.rspamd, "learn", _learn)
|
||||
|
||||
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
|
||||
assert taught == [], "taught from a corpus too lopsided to learn from"
|
||||
|
||||
|
||||
async def test_bootstrap_caps_ham_at_a_multiple_of_spam(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
marked: list[object] = []
|
||||
|
||||
async def _settings(_u: object) -> policy.Settings:
|
||||
return SETTINGS
|
||||
|
||||
class _Kv:
|
||||
async def get(self, _k: str, _d: object = None) -> list[str]:
|
||||
return []
|
||||
|
||||
async def set(self, _k: str, v: object) -> None:
|
||||
marked.append(v)
|
||||
|
||||
class _M:
|
||||
async def sample(self, *_a: object, **kw: object) -> list[uuid.UUID]:
|
||||
n = 20 if kw.get("special_use") == "junk" else 1000
|
||||
return [uuid.uuid4() for _ in range(n)]
|
||||
|
||||
async def raw(self, *_a: object, **_k: object) -> bytes:
|
||||
return b"x"
|
||||
|
||||
class _Fake:
|
||||
messages = _M()
|
||||
published: list[object] = []
|
||||
|
||||
def kv(self, _u: object) -> _Kv:
|
||||
return _Kv()
|
||||
|
||||
async def publish(self, _e: str, _p: object) -> None:
|
||||
return None
|
||||
|
||||
taught: list[bool] = []
|
||||
|
||||
async def _learn(_raw: bytes, **kw: object) -> bool:
|
||||
taught.append(bool(kw.get("spam")))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(policy, "load_settings", _settings)
|
||||
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
||||
monkeypatch.setattr(jobs.rspamd, "learn", _learn)
|
||||
monkeypatch.setattr(jobs, "PAUSE_SECONDS", 0)
|
||||
|
||||
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
|
||||
assert taught.count(True) == 20
|
||||
assert taught.count(False) == 60, "ham should be capped at 3x the spam count"
|
||||
assert marked, "a completed bootstrap must mark the account done"
|
||||
|
||||
Reference in New Issue
Block a user