feat(6251): the rule arms can be reviewed — menus_to_review re-runs prompt_rule, pre_tool_rule and write_path_rule
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) Failing after 1m26s
CI & Build / Build & push image (push) Skipped

A rule arm logs its query, floor, budget and project, which is everything
semantic_search_rules takes, so a logged call is re-run to a depth past its
budget and judged like an auto_inject menu. within_budget is the arm's own
cut: inside the budget and, for the banded command and write arms, inside the
band of the top hit. "Opened after" reads rule pulls, and rules created
after the call are dropped as notes are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 19:56:17 -04:00
co-authored by Claude Opus 5.5
parent 4297c88cd2
commit 7c1d98dd2a
3 changed files with 179 additions and 16 deletions
+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(