Antispam: bootstrap every account, not just the ones we happen to see go live
Warming Bayes hung on a single event — `account.state_changed` reaching "live". That covers an account added from now on and nothing else. An account that went live before this plugin existed never gets one; neither does one whose Junk folder was empty at the time and filled up later, or one whose owner enabled spam-folder sync afterwards. A worker restarting at the wrong moment loses it too. The result was a feature that quietly required someone to run it by hand per account, which is not a feature. An hourly sweep now walks every live account and offers each a bootstrap. That works because the per-account job was already cheap to repeat: it returns at once for an account already done, and declines — without marking it done — when the corpus is still too small to learn from. So the sweep simply keeps asking until there is something worth learning, then does it once. `s.accounts.live()` is the new SDK surface it needs: cross-user by design, because a sweep runs for the instance rather than a request. Ids and state only, so reading anything still goes through the user-scoped accessors. One failing account cannot stop the sweep — the mailbox that raises is logged and the rest still get their turn. docs/plugins.md now says this in general terms next to `JobSpec`: a state-change hook is one-shot, so pair it with a sweep when the work must eventually happen for everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -280,3 +280,51 @@ async def test_bootstrap_caps_ham_at_a_multiple_of_spam(monkeypatch: pytest.Monk
|
||||
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"
|
||||
|
||||
|
||||
# --- the sweep: bootstrapping must not depend on catching an event -----------
|
||||
|
||||
|
||||
async def test_sweep_visits_every_live_account(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An account that went live before the plugin existed still gets a warm start."""
|
||||
visited: list[tuple[str, str]] = []
|
||||
accounts = [(uuid.uuid4(), uuid.uuid4()) for _ in range(3)]
|
||||
|
||||
class _Accounts:
|
||||
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
|
||||
return accounts
|
||||
|
||||
class _Fake:
|
||||
accounts = _Accounts()
|
||||
|
||||
async def _one(_ctx: object, *, user_id: str, account_id: str) -> None:
|
||||
visited.append((user_id, account_id))
|
||||
|
||||
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
||||
monkeypatch.setattr(jobs, "bootstrap_account", _one)
|
||||
await jobs.bootstrap_sweep({})
|
||||
assert visited == [(str(u), str(a)) for u, a in accounts]
|
||||
|
||||
|
||||
async def test_one_failing_account_does_not_stop_the_sweep(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
done: list[str] = []
|
||||
accounts = [(uuid.uuid4(), uuid.uuid4()) for _ in range(3)]
|
||||
|
||||
class _Accounts:
|
||||
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
|
||||
return accounts
|
||||
|
||||
class _Fake:
|
||||
accounts = _Accounts()
|
||||
|
||||
async def _one(_ctx: object, *, user_id: str, account_id: str) -> None:
|
||||
if account_id == str(accounts[1][1]):
|
||||
raise RuntimeError("that mailbox is on fire")
|
||||
done.append(account_id)
|
||||
|
||||
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
||||
monkeypatch.setattr(jobs, "bootstrap_account", _one)
|
||||
await jobs.bootstrap_sweep({})
|
||||
assert done == [str(accounts[0][1]), str(accounts[2][1])]
|
||||
|
||||
Reference in New Issue
Block a user