dev → main: rule-arm review, moment usage by rule, no-subject auto-inject, copy check, S3 test wording #215

Merged
bvandeusen merged 15 commits from dev into main 2026-10-22 20:26:40 -04:00
3 changed files with 179 additions and 16 deletions
Showing only changes of commit 7c1d98dd2a - Show all commits
+5 -1
View File
@@ -42,7 +42,11 @@ async def menus_to_review(
Args:
source: the arm to review. Only arms whose search can be re-run
exactly are offered; today that is `auto_inject`.
exactly are offered: `auto_inject`, and the rule arms
`prompt_rule`, `pre_tool_rule` and `write_path_rule`. A rule
line's passage is the trigger the arm matched, and for the
command and write arms `within_budget` also needs the line to
sit inside the band of the top hit, as it did when shown.
n: how many calls to sample, 1–20. Random among the calls you have not
judged, so a review does not describe one afternoon.
days: how far back to sample from.
+74 -12
View File
@@ -46,13 +46,18 @@ from grimoire.models.base import iso
from grimoire.models.note_usage import PULLED, NoteUsageEvent
from grimoire.models.retrieval_judgment import VERDICTS, RetrievalJudgment
from grimoire.models.retrieval_log import RetrievalLog
from grimoire.models.rule_usage import PULLED as RULE_PULLED
from grimoire.models.rule_usage import RuleUsageEvent
logger = logging.getLogger(__name__)
# The arms a review can re-run FAITHFULLY. A re-run with different parameters
# from the arm's would judge a menu nobody was shown, so a surface joins this
# only with its own search spelled out in `_rerun`.
REVIEWABLE = ("auto_inject",)
# only with its own search spelled out in `_rerun`. The rule arms named here
# are the ones that annotate: each logs its query, floor, budget and project,
# which is everything its search takes.
RULE_REVIEWABLE = ("prompt_rule", "pre_tool_rule", "write_path_rule")
REVIEWABLE = ("auto_inject", *RULE_REVIEWABLE)
DEPTH_DEFAULT = 6
DEPTH_MAX = 10
@@ -94,7 +99,58 @@ def _after(stamp, call) -> bool:
async def _rerun(
user_id: int, row: RetrievalLog, depth: int, report: dict | None = None,
) -> list[dict]:
"""The logged call's query, searched again the way its arm searches.
"""The logged call's query, searched again the way its arm searches."""
if row.source in RULE_REVIEWABLE:
return await _rerun_rules(user_id, row, depth, report)
return await _rerun_notes(user_id, row, depth, report)
async def _rerun_rules(
user_id: int, row: RetrievalLog, depth: int, report: dict | None = None,
) -> list[dict]:
"""A rule arm's call, searched again: its query against rule triggers,
with the floor and project it logged.
`within_budget` is the arm's own cut: a line inside the budget, and, for
an arm that bands, inside the band of the top hit. The preference slot is
left out — it logs its own source. Rules created after the call are
dropped and named in `report["postdated"]`, as for notes.
"""
from grimoire.services import retrieval_pipeline as rp
from grimoire.services.embeddings import semantic_search_rules
arm = next(a for a in rp.RULE_ARMS if a.source == row.source)
hits = await semantic_search_rules(
user_id, row.query or "",
limit=depth + POSTDATED_SLACK,
threshold=row.threshold if row.threshold is not None else 0.0,
project_id=row.project_id or None,
)
postdated = [int(r.id) for _s, r in hits if _after(r.created_at, row.created_at)]
if report is not None:
report["postdated"] = postdated
hits = [h for h in hits if int(h[1].id) not in postdated][:depth]
budget = int(row.limit_n or 0)
cut = hits[:budget]
kept = {int(r.id) for _s, r in (rp._rule_band(cut) if arm.band else cut)}
return [{
"rank": rank,
"record_id": int(rule.id),
"score": round(float(score), 4),
"within_budget": int(rule.id) in kept,
"changed_since_call": _after(rule.updated_at, row.created_at),
"kind": rule.kind or "rule",
"name": rule.title,
# The trigger is what the arm matched against and what its line
# quotes, so it is the passage the reader saw.
"passage": rule.when_to_apply or rule.statement or None,
} for rank, (score, rule) in enumerate(hits, start=1)]
async def _rerun_notes(
user_id: int, row: RetrievalLog, depth: int, report: dict | None = None,
) -> list[dict]:
"""A notes arm's call, searched again.
`auto_inject`'s parameters, read from its spec (`retrieval_pipeline.
AUTO_INJECT`) rather than restated: which kinds, whose records, and the
@@ -151,19 +207,25 @@ async def _opened_after(user_id: int, row: RetrievalLog, ids: list[int]) -> set[
Agent pulls only — the `mcp_` prefix, per NoteUsageEvent's convention —
because the question is whether the line moved the session, not whether
the operator later browsed to the record.
the operator later browsed to the record. A rule arm's lines are read
from the rule table: a note and a rule can share an id.
"""
if not ids or row.created_at is None:
return set()
table, id_col, pulled = (
(RuleUsageEvent, RuleUsageEvent.rule_id, RULE_PULLED)
if row.source in RULE_REVIEWABLE
else (NoteUsageEvent, NoteUsageEvent.note_id, PULLED)
)
async with async_session() as session:
found = await session.execute(
select(NoteUsageEvent.note_id).distinct().where(
NoteUsageEvent.user_id == user_id,
NoteUsageEvent.event == PULLED,
NoteUsageEvent.source.startswith("mcp_", autoescape=True),
NoteUsageEvent.note_id.in_(ids),
NoteUsageEvent.created_at >= row.created_at,
NoteUsageEvent.created_at <= row.created_at + OPENED_WINDOW,
select(id_col).distinct().where(
table.user_id == user_id,
table.event == pulled,
table.source.startswith("mcp_", autoescape=True),
id_col.in_(ids),
table.created_at >= row.created_at,
table.created_at <= row.created_at + OPENED_WINDOW,
)
)
return {int(i) for (i,) in found.all()}
@@ -175,7 +237,7 @@ async def _logged_after(row: RetrievalLog, ids: list[int]) -> set[int]:
(a closed task takes no claim stamp), so it is read here."""
from grimoire.models.task_log import TaskLog
if not ids or row.created_at is None:
if not ids or row.created_at is None or row.source in RULE_REVIEWABLE:
return set()
async with async_session() as session:
found = await session.execute(
+100 -3
View File
@@ -81,7 +81,7 @@ async def test_the_re_run_searches_the_way_the_arm_does():
rerun_kw.update(kw)
return []
row = SimpleNamespace(query="q", threshold=0.5, limit_n=3, project_id=None,
row = SimpleNamespace(source="auto_inject", query="q", threshold=0.5, limit_n=3, project_id=None,
created_at=CALL)
with patch("grimoire.services.embeddings.semantic_search_notes", side_effect=rerun_search):
await review._rerun(1, row, 3)
@@ -99,7 +99,7 @@ BEFORE = CALL - timedelta(days=1)
@pytest.mark.asyncio
async def test_the_re_run_ranks_from_one_and_marks_the_budget_cut():
row = SimpleNamespace(query="q", threshold=0.55, limit_n=2, project_id=None,
row = SimpleNamespace(source="auto_inject", query="q", threshold=0.55, limit_n=2, project_id=None,
created_at=CALL)
hits = [
(0.81, fake_note(id=11, title="first", created_at=BEFORE, updated_at=BEFORE)),
@@ -127,7 +127,7 @@ async def test_a_record_written_after_the_call_is_dropped_before_ranking():
"""The session that made a call goes on to write about it, and that record
outranks everything in a re-run. The call could never have been offered
it, so it must not take a rank — or a verdict — from what was."""
row = SimpleNamespace(query="q", threshold=0.55, limit_n=1, project_id=None,
row = SimpleNamespace(source="auto_inject", query="q", threshold=0.55, limit_n=1, project_id=None,
created_at=CALL)
after = CALL + timedelta(hours=1)
hits = [
@@ -147,6 +147,103 @@ async def test_a_record_written_after_the_call_is_dropped_before_ranking():
for ln in lines] == [(1, 22, True, False), (2, 23, False, True)]
# ── the rule arms ────────────────────────────────────────────────────────
def _rule(rid, title, created=BEFORE, updated=BEFORE):
return SimpleNamespace(
id=rid, title=title, kind="rule", when_to_apply=f"when {title}",
statement="s", created_at=created, updated_at=updated,
)
def test_the_reviewable_rule_arms_are_the_ones_that_annotate():
"""Every rule arm that shows lines can be reviewed; the reply backstop
shows none, so there is no menu to judge."""
from grimoire.services import retrieval_pipeline as rp
assert set(review.RULE_REVIEWABLE) == {
a.source for a in rp.RULE_ARMS if not a.stop_only
}
@pytest.mark.asyncio
async def test_the_rule_re_run_searches_the_way_the_arm_does():
"""The floor and the project the arm searched with, run and compared."""
from grimoire.services import retrieval_pipeline as rp
arm_kw: dict = {}
async def arm_search(_uid, _q, **kw):
if "kind" not in kw:
arm_kw.update(kw)
return []
await rp.run_rule_arm(
rp.PROMPT_RULE, rp.RuleMoment(user_id=1, query="q", project_id=7),
floor=0.7, budget=3,
io=rp.RuleIO(search=arm_search, record_retrieval=MagicMock(),
record_rule_surfaced=MagicMock()),
)
rerun_kw: dict = {}
async def rerun_search(_uid, _q, **kw):
rerun_kw.update(kw)
return []
row = SimpleNamespace(source="prompt_rule", query="q", threshold=0.7, limit_n=3,
project_id=7, created_at=CALL)
with patch("grimoire.services.embeddings.semantic_search_rules", side_effect=rerun_search):
await review._rerun(1, row, 3)
for key in ("threshold", "project_id"):
assert rerun_kw[key] == arm_kw[key], key
assert "kind" not in rerun_kw
@pytest.mark.asyncio
@pytest.mark.parametrize(("source", "within"), [
# No band: the budget alone is the cut.
("prompt_rule", [True, True, True, False]),
# Banded: the third sits outside the band of the top hit, so it was
# not shown though the budget had room for it.
("pre_tool_rule", [True, True, False, False]),
])
async def test_a_rule_line_is_inside_the_cut_only_where_the_arm_showed_it(source, within):
row = SimpleNamespace(source=source, query="git push", threshold=0.7, limit_n=3,
project_id=None, created_at=CALL)
hits = [(0.86, _rule(1, "one")), (0.84, _rule(2, "two")),
(0.76, _rule(3, "three")), (0.74, _rule(4, "four"))]
async def search(_uid, _q, **kw):
assert kw["threshold"] == 0.7 and kw["project_id"] is None
return hits
with patch("grimoire.services.embeddings.semantic_search_rules", side_effect=search):
lines = await review._rerun(1, row, 4)
assert [ln["within_budget"] for ln in lines] == within
assert lines[0]["passage"] == "when one"
assert lines[0]["kind"] == "rule"
@pytest.mark.asyncio
async def test_a_rule_written_after_the_call_is_dropped_before_ranking():
row = SimpleNamespace(source="write_path_rule", query="q", threshold=0.7, limit_n=2,
project_id=None, created_at=CALL)
after = CALL + timedelta(hours=1)
hits = [(0.9, _rule(31, "written after", created=after, updated=after)),
(0.8, _rule(32, "held then"))]
async def search(_uid, _q, **kw):
return hits
rep: dict = {}
with patch("grimoire.services.embeddings.semantic_search_rules", side_effect=search):
lines = await review._rerun(1, row, 2, report=rep)
assert rep["postdated"] == [31]
assert [(ln["rank"], ln["record_id"]) for ln in lines] == [(1, 32)]
# ── against Postgres ─────────────────────────────────────────────────────