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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user