refactor(retrieval): the notes arms run on the one pipeline - auto_inject, its reuse and lesson slots, the write path by meaning, and rule_via_lesson are specs (milestone 456 step 4, #4906)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / Build & push image (push) Successful in 35s

retrieval_pipeline gains the notes half: NoteArm / NoteSlot / NoteMoment /
NoteIO / NoteResult and run_note_arm, which writes once the stages both
notes arms copied: search, withhold this response's own menu (#3739),
fresh/repeat split, the call row before any return (#3497, #3752), the
band, the reserved slots in their order (reuse evicts, lesson extends),
and the surfacing rows. The note renderer (_record_kind, _menu_name,
_menu_passage, the seen pointer, menu_entry) moves with it, and
run_via_lesson_arm takes rule_via_lesson.

Behaviour-preserving, with flags for today's differences: notes still log
BEFORE the band and rules after it (step 7's question). One deliberate
change: a notes arm now fails open like the rule arms, so a failing
search costs its lines and no longer the whole hook response.

The I/O is resolved from plugin_context at call time (_note_io), so the
existing patches keep working. The review re-run reads the AUTO_INJECT
spec instead of restating it, and its guard now compares the two live
searches. The registry declares the pipeline's notes fan-out sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 20:02:37 -04:00
co-authored by Claude Opus 5.5
parent 4b1060ae8b
commit 2b8f41229d
10 changed files with 1172 additions and 763 deletions
+37 -25
View File
@@ -5,12 +5,9 @@ Integration tests pin the parts a mock would make true by construction: which
logged calls are offered, that a verdict lands and replaces, that "opened
after" reads the usage table, and that the readout counts by rank.
"""
import ast
import inspect
import textwrap
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
@@ -54,31 +51,46 @@ async def test_an_empty_verdict_list_is_refused():
# ── the re-run replays the arm ───────────────────────────────────────────
def _search_kwargs(fn) -> dict:
"""The constant keywords `fn` passes to semantic_search_notes."""
tree = ast.parse(textwrap.dedent(inspect.getsource(fn)))
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and getattr(node.func, "id", "")
== "semantic_search_notes"):
return {
kw.arg: kw.value.value for kw in node.keywords
if isinstance(kw.value, ast.Constant)
}
return {}
_FILTERS = ("note_type", "task_kind", "include_global_kinds", "scope")
def test_the_re_run_searches_the_way_the_arm_does():
@pytest.mark.asyncio
async def test_the_re_run_searches_the_way_the_arm_does():
"""A re-run with different visibility or kinds from the arm's would judge
a menu nobody was shown. Read from both call sites, so a change to the
arm's search fails here until the review follows it."""
from scribe.services.plugin_context import build_autoinject_hint
a menu nobody was shown. Both searches are RUN and their keywords
compared, so a change to the arm's spec fails here until the review
follows it — and the arm has to have filters for the check to mean
anything (rule 167)."""
from scribe.services import retrieval_pipeline as rp
arm = _search_kwargs(build_autoinject_hint)
rerun = _search_kwargs(review._rerun)
assert arm, "found no semantic_search_notes call in the arm"
for key in ("include_global_kinds", "scope"):
assert key in arm, f"the arm no longer passes {key} as a constant"
assert rerun.get(key) == arm[key], key
arm_kw: dict = {}
async def arm_search(_uid, _q, **kw):
arm_kw.update(kw)
return []
await rp.run_note_arm(
rp.AUTO_INJECT, rp.NoteMoment(user_id=1, query="q", project_id=None),
floor=0.5, budget=3,
io=rp.NoteIO(search=arm_search, record_retrieval=MagicMock(),
record_surfaced=MagicMock()),
)
rerun_kw: dict = {}
async def rerun_search(_uid, _q, **kw):
rerun_kw.update(kw)
return []
row = SimpleNamespace(query="q", threshold=0.5, limit_n=3, project_id=None,
created_at=CALL)
with patch("scribe.services.embeddings.semantic_search_notes", side_effect=rerun_search):
await review._rerun(1, row, 3)
arm = {k: arm_kw[k] for k in _FILTERS if k in arm_kw}
assert "scope" in arm and "include_global_kinds" in arm, (
f"the arm no longer passes its visibility filters: {arm_kw}"
)
assert {k: rerun_kw[k] for k in _FILTERS if k in rerun_kw} == arm
CALL = datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc)