refactor(retrieval): the rule builders compose one moment - _rule_moment runs the arm and its via-lesson step for all three (milestone 456 step 6, #4908)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 28s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 28s
build_prompt_rule_hint, build_tool_rule_hint and build_write_path_hint each ran the rule arm and then the via-lesson step by hand, the first two passing the query between them through a _via_query key on the payload. They now call one composer, _rule_moment, and the split helpers (_prompt_rule_hint, _tool_rule_hint, _add_rules_via_lessons) and the side channel are deleted. Output shapes are unchanged: the prompt builder returns no checkpoint key, the tool builder always does, and shown_rule_ids stays the direct band. A structural test pins that only _rule_moment runs a rule arm or the via-lesson step, and that the three builders are its only callers. plugin_context.py: 2,418 -> 2,361 lines this step; 3,558 when the milestone began. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -998,31 +998,33 @@ async def _rules_via_lessons(
|
|||||||
return result.lines, result.rule_ids
|
return result.lines, result.rule_ids
|
||||||
|
|
||||||
|
|
||||||
async def _add_rules_via_lessons(
|
async def _rule_moment(
|
||||||
user_id: int, out: dict, *, project_id: int, exclude_rule_ids, held_rule_ids,
|
arm: rp.RuleArm, moment: rp.RuleMoment, *, floor: float, budget: int,
|
||||||
where: str,
|
checkpoint_floor: float = 0.0,
|
||||||
) -> dict:
|
) -> rp.RuleResult:
|
||||||
"""Run the via-lesson step after a direct rule arm, on the query that arm
|
"""One rule moment, composed: the direct arm, then the rules its matching
|
||||||
actually searched with.
|
lessons bring in (#4633) — after the band and never in place of it.
|
||||||
|
|
||||||
The arm leaves `_via_query` in its payload only when it RAN — enabled, and
|
Every rule builder below calls this ONE function, where each used to run
|
||||||
with something to search — so an arm the operator switched off, or a
|
the arm and then the via-lesson step by hand, passing the query between
|
||||||
blank prompt, brings no rule in through a side door either. The key is
|
them through a key on the payload. The via-lesson step searches with the
|
||||||
popped here; it never leaves the server.
|
query the arm searched with, and skips what the arm named plus what the
|
||||||
|
session's ledger holds — suppression is by RULE, whichever lesson reached
|
||||||
|
it. Its lines and fresh ids join the arm's; `shown_rule_ids` stays the
|
||||||
|
direct arm's band, which is what the soft-link recorder pairs (#4637).
|
||||||
"""
|
"""
|
||||||
query = out.pop("_via_query", "")
|
result = await rp.run_rule_arm(
|
||||||
if not query:
|
arm, moment, floor=floor, budget=budget, io=_rule_io(),
|
||||||
return out
|
checkpoint_floor=checkpoint_floor,
|
||||||
skip = (set(exclude_rule_ids or []) | set(out.get("rule_ids") or [])
|
|
||||||
| set(out.get("shown_rule_ids") or []))
|
|
||||||
lines, ids = await _rules_via_lessons(
|
|
||||||
user_id, query, project_id=project_id or None, skip=skip,
|
|
||||||
held=set(held_rule_ids or []), where=where,
|
|
||||||
)
|
)
|
||||||
if lines:
|
lines, ids = await _rules_via_lessons(
|
||||||
out["context"] = "\n".join(c for c in (out.get("context") or "", *lines) if c)
|
moment.user_id, moment.query, project_id=moment.project_id or None,
|
||||||
out["rule_ids"] = list(out.get("rule_ids") or []) + ids
|
skip=set(moment.exclude) | set(result.shown_rule_ids),
|
||||||
return out
|
held=set(moment.held), where=moment.where,
|
||||||
|
)
|
||||||
|
result.lines.extend(lines)
|
||||||
|
result.rule_ids.extend(ids)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def build_prompt_rule_hint(
|
async def build_prompt_rule_hint(
|
||||||
@@ -1033,28 +1035,6 @@ async def build_prompt_rule_hint(
|
|||||||
exclude_rule_ids: list[int] | None = None,
|
exclude_rule_ids: list[int] | None = None,
|
||||||
held_rule_ids: list[int] | None = None,
|
held_rule_ids: list[int] | None = None,
|
||||||
context: str = "",
|
context: str = "",
|
||||||
) -> dict:
|
|
||||||
"""Rules and preferences that may apply to what the operator just asked —
|
|
||||||
matched directly (`_prompt_rule_hint`, where the design is written), then
|
|
||||||
reached through a linked lesson (`_rules_via_lessons`)."""
|
|
||||||
out = await _prompt_rule_hint(
|
|
||||||
user_id, query, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
|
|
||||||
held_rule_ids=held_rule_ids, context=context,
|
|
||||||
)
|
|
||||||
return await _add_rules_via_lessons(
|
|
||||||
user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
|
|
||||||
held_rule_ids=held_rule_ids, where="to this request",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _prompt_rule_hint(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
*,
|
|
||||||
project_id: int = 0,
|
|
||||||
exclude_rule_ids: list[int] | None = None,
|
|
||||||
held_rule_ids: list[int] | None = None,
|
|
||||||
context: str = "",
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Rules and preferences that may apply to what the operator just asked.
|
"""Rules and preferences that may apply to what the operator just asked.
|
||||||
|
|
||||||
@@ -1081,6 +1061,10 @@ async def _prompt_rule_hint(
|
|||||||
rules unquoted separates the two claims visually with no extra prose, and
|
rules unquoted separates the two claims visually with no extra prose, and
|
||||||
matches how a rule line already renders on both act arms.
|
matches how a rule line already renders on both act arms.
|
||||||
|
|
||||||
|
NO `checkpoint` KEY (#4214): the act arms can hold a call because there
|
||||||
|
is a composed act to hold, while this one fires before anything has been
|
||||||
|
decided.
|
||||||
|
|
||||||
Fails open and returns empty context on any error, like its siblings: a
|
Fails open and returns empty context on any error, like its siblings: a
|
||||||
recall aid may never break the operator's prompt.
|
recall aid may never break the operator's prompt.
|
||||||
"""
|
"""
|
||||||
@@ -1093,16 +1077,12 @@ async def _prompt_rule_hint(
|
|||||||
# names it — and "yes, go ahead" names nothing a trigger can match, while
|
# names it — and "yes, go ahead" names nothing a trigger can match, while
|
||||||
# the reply it answers ("commit this to dev and push") does.
|
# the reply it answers ("commit this to dev and push") does.
|
||||||
q = _autoinject_query(q, context)
|
q = _autoinject_query(q, context)
|
||||||
# The query this arm searches with, for the via-lesson step that follows
|
|
||||||
# it (#4633) — set only once the arm is going to run.
|
|
||||||
out["_via_query"] = q
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# SCOPED TO THIS SESSION'S PROJECT (milestone 414): global rules plus
|
# SCOPED TO THIS SESSION'S PROJECT (milestone 414): global rules plus
|
||||||
# the bound project's own. An unbound session (project_id 0) gets
|
# the bound project's own. An unbound session (project_id 0) gets
|
||||||
# global rules only — this surface speaks unasked, and a whole-rulebook
|
# global rules only — this surface speaks unasked, and a whole-rulebook
|
||||||
# answer is only right for someone who asked the whole rulebook.
|
# answer is only right for someone who asked the whole rulebook.
|
||||||
result = await rp.run_rule_arm(
|
result = await _rule_moment(
|
||||||
rp.PROMPT_RULE,
|
rp.PROMPT_RULE,
|
||||||
rp.RuleMoment(
|
rp.RuleMoment(
|
||||||
user_id=user_id, query=q, project_id=project_id,
|
user_id=user_id, query=q, project_id=project_id,
|
||||||
@@ -1112,14 +1092,13 @@ async def _prompt_rule_hint(
|
|||||||
),
|
),
|
||||||
floor=await floor_for(user_id, "prompt_rule"),
|
floor=await floor_for(user_id, "prompt_rule"),
|
||||||
budget=await budget_for(user_id, "prompt_rule"),
|
budget=await budget_for(user_id, "prompt_rule"),
|
||||||
io=_rule_io(),
|
|
||||||
)
|
)
|
||||||
if result.lines:
|
if result.lines:
|
||||||
out["context"] = "\n".join(result.lines)
|
out["context"] = "\n".join(result.lines)
|
||||||
out["rule_ids"] = result.rule_ids
|
out["rule_ids"] = result.rule_ids
|
||||||
# Every rule LINE, repeats and the reserved slot included — for
|
# Every rule LINE the direct arm showed, repeats and the reserved
|
||||||
# the soft-link recorder (#4637). Distinct from `rule_ids`, which
|
# slot included — for the soft-link recorder (#4637). Distinct
|
||||||
# is telemetry's fresh-only cut.
|
# from `rule_ids`, which is telemetry's fresh-only cut.
|
||||||
out["shown_rule_ids"] = result.shown_rule_ids
|
out["shown_rule_ids"] = result.shown_rule_ids
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("prompt rule arm failed", exc_info=True)
|
logger.debug("prompt rule arm failed", exc_info=True)
|
||||||
@@ -1877,11 +1856,13 @@ async def build_write_path_hint(
|
|||||||
# with no body), and a line says the rule may apply "here".
|
# with no body), and a line says the rule may apply "here".
|
||||||
#
|
#
|
||||||
# Fails open like every other arm: a rule hint must never break a write.
|
# Fails open like every other arm: a rule hint must never break a write.
|
||||||
|
# The direct band, then a rule reached through a linked lesson (#4633),
|
||||||
|
# composed by `_rule_moment` exactly as the other two rule builders are.
|
||||||
rule_ids: list[int] = []
|
rule_ids: list[int] = []
|
||||||
shown_rule_ids: list[int] = []
|
shown_rule_ids: list[int] = []
|
||||||
checkpoint: dict = {}
|
checkpoint: dict = {}
|
||||||
try:
|
try:
|
||||||
result = await rp.run_rule_arm(
|
result = await _rule_moment(
|
||||||
rp.WRITE_PATH_RULE,
|
rp.WRITE_PATH_RULE,
|
||||||
rp.RuleMoment(
|
rp.RuleMoment(
|
||||||
user_id=user_id, query=code or path, project_id=project_id,
|
user_id=user_id, query=code or path, project_id=project_id,
|
||||||
@@ -1890,28 +1871,17 @@ async def build_write_path_hint(
|
|||||||
held=frozenset(held_rule_ids or []),
|
held=frozenset(held_rule_ids or []),
|
||||||
),
|
),
|
||||||
floor=cfg["rule_threshold"], budget=cfg["rule_top_k"],
|
floor=cfg["rule_threshold"], budget=cfg["rule_top_k"],
|
||||||
io=_rule_io(), checkpoint_floor=cfg["checkpoint_threshold"],
|
checkpoint_floor=cfg["checkpoint_threshold"],
|
||||||
)
|
)
|
||||||
lines.extend(result.lines)
|
lines.extend(result.lines)
|
||||||
rule_ids.extend(result.rule_ids)
|
rule_ids.extend(result.rule_ids)
|
||||||
# Every rule line, repeats included — what the soft-link recorder
|
# Every rule line the band showed, repeats included — what the
|
||||||
# pairs with this response's lessons (#4637).
|
# soft-link recorder pairs with this response's lessons (#4637).
|
||||||
shown_rule_ids = result.shown_rule_ids
|
shown_rule_ids = result.shown_rule_ids
|
||||||
checkpoint = result.checkpoint
|
checkpoint = result.checkpoint
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("write-path rule arm failed", exc_info=True)
|
logger.debug("write-path rule arm failed", exc_info=True)
|
||||||
|
|
||||||
# A rule reached through a linked lesson (#4633), after the direct band
|
|
||||||
# and never in place of it. Skips what the band already named and what
|
|
||||||
# the session's ledger holds — suppression is by rule.
|
|
||||||
via_lines, via_ids = await _rules_via_lessons(
|
|
||||||
user_id, code or path, project_id=project_id or None,
|
|
||||||
skip=set(exclude_rule_ids or []) | set(shown_rule_ids),
|
|
||||||
held=set(held_rule_ids or []), where="here",
|
|
||||||
)
|
|
||||||
lines.extend(via_lines)
|
|
||||||
rule_ids.extend(via_ids)
|
|
||||||
|
|
||||||
# A lesson and a rule on this one response (#4637): the soft-link
|
# A lesson and a rule on this one response (#4637): the soft-link
|
||||||
# recorder counts the pair, keyed on the FILE — every edit to one file is
|
# recorder counts the pair, keyed on the FILE — every edit to one file is
|
||||||
# one situation — and asks about it once the evidence holds. Fails open.
|
# one situation — and asks about it once the evidence holds. Fails open.
|
||||||
@@ -1954,54 +1924,9 @@ async def build_tool_rule_hint(
|
|||||||
cwd: str = "",
|
cwd: str = "",
|
||||||
seen_ruling_systems: list[int] | None = None,
|
seen_ruling_systems: list[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Standing rules that may apply to the action about to be taken — matched
|
"""Standing rules that may apply to the ACTION about to be taken (#3476) —
|
||||||
directly (`_tool_rule_hint`, where the design is written), then reached
|
matched directly, then reached through a linked lesson (`_rule_moment`) —
|
||||||
through a linked lesson (`_rules_via_lessons`, #4633) — and the rulings of
|
and the rulings of any area whose files the command names (milestone 444).
|
||||||
any area whose files the command names (milestone 444).
|
|
||||||
|
|
||||||
`root` and `cwd` are the repo's absolute root and the command's working
|
|
||||||
directory, from the hook; they are what turn the paths a command names
|
|
||||||
into the repo-relative paths a System's patterns are written in."""
|
|
||||||
out = await _tool_rule_hint(
|
|
||||||
user_id, tool_name, command, project_id=project_id,
|
|
||||||
exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
|
||||||
)
|
|
||||||
out = await _add_rules_via_lessons(
|
|
||||||
user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
|
|
||||||
held_rule_ids=held_rule_ids, where=f"to this {tool_name} call",
|
|
||||||
)
|
|
||||||
# Its own arm, not part of the rule search: a lookup by path, with no
|
|
||||||
# floor and no budget, so it adds to the rule lines rather than competing
|
|
||||||
# with them. First, because a ruling is the operator's own decision.
|
|
||||||
# `ruling_system_ids` is set only when a ruling was shown: this arm fires
|
|
||||||
# on every command, and the hook reads an absent list as an empty one.
|
|
||||||
try:
|
|
||||||
paths = system_rulings_svc.command_paths(command, root=root, cwd=cwd) if project_id else []
|
|
||||||
if paths and (await get_writepath_config(user_id)).get("enabled"):
|
|
||||||
rulings = await system_rulings_svc.rulings_for_paths(
|
|
||||||
user_id, project_id, paths,
|
|
||||||
seen=seen_ruling_systems, source="rulings_pre_tool",
|
|
||||||
)
|
|
||||||
if rulings["lines"]:
|
|
||||||
out["context"] = "\n".join(
|
|
||||||
rulings["lines"] + ([out["context"]] if out.get("context") else [])
|
|
||||||
)
|
|
||||||
out["ruling_system_ids"] = rulings["system_ids"]
|
|
||||||
except Exception:
|
|
||||||
logger.debug("pre-tool rulings arm failed", exc_info=True)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def _tool_rule_hint(
|
|
||||||
user_id: int,
|
|
||||||
tool_name: str,
|
|
||||||
command: str,
|
|
||||||
*,
|
|
||||||
project_id: int = 0,
|
|
||||||
exclude_rule_ids: list[int] | None = None,
|
|
||||||
held_rule_ids: list[int] | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Standing rules that may apply to the ACTION about to be taken (#3476).
|
|
||||||
|
|
||||||
The sibling of the write-path rule arm, and the surface that was missing.
|
The sibling of the write-path rule arm, and the surface that was missing.
|
||||||
That arm is keyed on `code or path`, so a rule can only be retrieved at the
|
That arm is keyed on `code or path`, so a rule can only be retrieved at the
|
||||||
@@ -2020,10 +1945,12 @@ async def _tool_rule_hint(
|
|||||||
which tools it watches, so widening the matcher is a `hooks.json` edit with
|
which tools it watches, so widening the matcher is a `hooks.json` edit with
|
||||||
no change here.
|
no change here.
|
||||||
|
|
||||||
EVERY TIER, since #3702 — the tier filter this docstring used to describe
|
EVERY TIER, since #3702 — present in context and salient at the moment are
|
||||||
is gone from both arms, for the reason recorded at RULEHINT_LIMIT: present
|
different properties, and only the second is what this arm is for.
|
||||||
in context and salient at the moment are different properties, and only the
|
|
||||||
second is what this arm is for.
|
`root` and `cwd` are the repo's absolute root and the command's working
|
||||||
|
directory, from the hook; they are what turn the paths a command names
|
||||||
|
into the repo-relative paths a System's patterns are written in.
|
||||||
|
|
||||||
Fails open and returns an empty context on any error: a recall aid may
|
Fails open and returns an empty context on any error: a recall aid may
|
||||||
never break the operator's action.
|
never break the operator's action.
|
||||||
@@ -2044,26 +1971,22 @@ async def _tool_rule_hint(
|
|||||||
cfg = await get_writepath_config(user_id)
|
cfg = await get_writepath_config(user_id)
|
||||||
if not cfg.get("enabled"):
|
if not cfg.get("enabled"):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# The command text is the query. A long heredoc or a pasted script
|
# The command text is the query. A long heredoc or a pasted script
|
||||||
# would otherwise push the meaningful head of the command out of the
|
# would otherwise push the meaningful head of the command out of the
|
||||||
# embedding window, so it is bounded — the verb and its target sit at
|
# embedding window, so it is bounded — the verb and its target sit at
|
||||||
# the front, which is the part a rule is about.
|
# the front, which is the part a rule is about.
|
||||||
query = command[:_TOOL_QUERY_CHARS]
|
result = await _rule_moment(
|
||||||
# For the via-lesson step (#4633) — set only once the arm is enabled.
|
|
||||||
out["_via_query"] = query
|
|
||||||
|
|
||||||
result = await rp.run_rule_arm(
|
|
||||||
rp.PRE_TOOL_RULE,
|
rp.PRE_TOOL_RULE,
|
||||||
rp.RuleMoment(
|
rp.RuleMoment(
|
||||||
user_id=user_id, query=query, project_id=project_id,
|
user_id=user_id, query=command[:_TOOL_QUERY_CHARS],
|
||||||
|
project_id=project_id,
|
||||||
where=f"to this {tool_name} call",
|
where=f"to this {tool_name} call",
|
||||||
checkpoint_where=f"this {tool_name} call",
|
checkpoint_where=f"this {tool_name} call",
|
||||||
exclude=frozenset(exclude_rule_ids or []),
|
exclude=frozenset(exclude_rule_ids or []),
|
||||||
held=frozenset(held_rule_ids or []),
|
held=frozenset(held_rule_ids or []),
|
||||||
),
|
),
|
||||||
floor=cfg["tool_rule_threshold"], budget=cfg["tool_rule_top_k"],
|
floor=cfg["tool_rule_threshold"], budget=cfg["tool_rule_top_k"],
|
||||||
io=_rule_io(), checkpoint_floor=cfg["checkpoint_threshold"],
|
checkpoint_floor=cfg["checkpoint_threshold"],
|
||||||
)
|
)
|
||||||
if result.lines:
|
if result.lines:
|
||||||
out["context"] = "\n".join(result.lines)
|
out["context"] = "\n".join(result.lines)
|
||||||
@@ -2071,6 +1994,26 @@ async def _tool_rule_hint(
|
|||||||
out["checkpoint"] = result.checkpoint
|
out["checkpoint"] = result.checkpoint
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("pre-tool rule arm failed", exc_info=True)
|
logger.debug("pre-tool rule arm failed", exc_info=True)
|
||||||
|
|
||||||
|
# Its own arm, not part of the rule search: a lookup by path, with no
|
||||||
|
# floor and no budget, so it adds to the rule lines rather than competing
|
||||||
|
# with them. First, because a ruling is the operator's own decision.
|
||||||
|
# `ruling_system_ids` is set only when a ruling was shown: this arm fires
|
||||||
|
# on every command, and the hook reads an absent list as an empty one.
|
||||||
|
try:
|
||||||
|
paths = system_rulings_svc.command_paths(command, root=root, cwd=cwd) if project_id else []
|
||||||
|
if paths and (await get_writepath_config(user_id)).get("enabled"):
|
||||||
|
rulings = await system_rulings_svc.rulings_for_paths(
|
||||||
|
user_id, project_id, paths,
|
||||||
|
seen=seen_ruling_systems, source="rulings_pre_tool",
|
||||||
|
)
|
||||||
|
if rulings["lines"]:
|
||||||
|
out["context"] = "\n".join(
|
||||||
|
rulings["lines"] + ([out["context"]] if out.get("context") else [])
|
||||||
|
)
|
||||||
|
out["ruling_system_ids"] = rulings["system_ids"]
|
||||||
|
except Exception:
|
||||||
|
logger.debug("pre-tool rulings arm failed", exc_info=True)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import pytest
|
|||||||
from scribe.services import plugin_context as pc
|
from scribe.services import plugin_context as pc
|
||||||
from scribe.services import rule_usage
|
from scribe.services import rule_usage
|
||||||
from scribe.services.retrieval_registry import POINTS
|
from scribe.services.retrieval_registry import POINTS
|
||||||
|
from scribe.services.retrieval_pipeline import RuleMoment, RuleResult
|
||||||
|
|
||||||
# Bound before conftest's autouse stub replaces the module attribute.
|
# Bound before conftest's autouse stub replaces the module attribute.
|
||||||
_REAL = pc._rules_via_lessons
|
_REAL = pc._rules_via_lessons
|
||||||
@@ -123,23 +124,56 @@ async def test_a_failure_brings_no_rule_and_raises_nothing():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_the_step_runs_only_when_the_arm_ran_and_never_leaks_its_key():
|
async def test_the_step_follows_the_arm_on_its_query_and_skips_what_it_named():
|
||||||
|
"""One composer for all three rule builders (milestone 456 step 6): the
|
||||||
|
via-lesson step searches with the query the arm searched with, skips the
|
||||||
|
session's ledger and every rule the arm's band named, and its lines and
|
||||||
|
fresh ids join the arm's — while `shown_rule_ids` stays the direct band."""
|
||||||
step = AsyncMock(return_value=(["Standing rule … Reached through lesson #41"], [7]))
|
step = AsyncMock(return_value=(["Standing rule … Reached through lesson #41"], [7]))
|
||||||
|
direct = RuleResult(lines=["direct line"], rule_ids=[5], shown_rule_ids=[5, 6])
|
||||||
|
moment = RuleMoment(user_id=1, query="q", project_id=2, where="here",
|
||||||
|
exclude=frozenset({3, 6}))
|
||||||
|
with patch.object(pc, "_rules_via_lessons", step), \
|
||||||
|
patch.object(pc.rp, "run_rule_arm", AsyncMock(return_value=direct)):
|
||||||
|
out = await pc._rule_moment(pc.rp.PROMPT_RULE, moment, floor=0.5, budget=3)
|
||||||
|
assert step.await_args.args[1] == "q"
|
||||||
|
assert step.await_args.kwargs["skip"] == {3, 5, 6}
|
||||||
|
assert out.rule_ids == [5, 7]
|
||||||
|
assert out.lines == ["direct line", "Standing rule … Reached through lesson #41"]
|
||||||
|
assert out.shown_rule_ids == [5, 6]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_blank_prompt_runs_neither_the_arm_nor_the_step():
|
||||||
|
step = AsyncMock(return_value=([], []))
|
||||||
with patch.object(pc, "_rules_via_lessons", step):
|
with patch.object(pc, "_rules_via_lessons", step):
|
||||||
idle = await pc._add_rules_via_lessons(
|
out = await pc.build_prompt_rule_hint(1, " ")
|
||||||
1, {"context": "", "rule_ids": []}, project_id=2,
|
assert out == {"context": "", "rule_ids": []}
|
||||||
exclude_rule_ids=[3], held_rule_ids=[], where="here",
|
step.assert_not_awaited()
|
||||||
)
|
|
||||||
ran = await pc._add_rules_via_lessons(
|
|
||||||
1, {"context": "direct line", "rule_ids": [5], "_via_query": "q"},
|
def test_only_the_composer_runs_a_rule_arm_or_the_via_lesson_step():
|
||||||
project_id=2, exclude_rule_ids=[3], held_rule_ids=[], where="here",
|
"""Structural (rule 167): every rule builder goes through `_rule_moment`,
|
||||||
)
|
so a builder that runs the arm or the via-lesson step by hand again is the
|
||||||
assert idle == {"context": "", "rule_ids": []}
|
copy this step removed coming back — and a count is what lets it fail."""
|
||||||
assert step.await_count == 1
|
import ast
|
||||||
assert step.await_args.kwargs["skip"] == {3, 5}
|
from pathlib import Path
|
||||||
assert "_via_query" not in ran
|
|
||||||
assert ran["rule_ids"] == [5, 7]
|
tree = ast.parse(Path("src/scribe/services/plugin_context.py").read_text())
|
||||||
assert ran["context"].startswith("direct line\n")
|
callers: dict[str, set[str]] = {}
|
||||||
|
for fn in ast.walk(tree):
|
||||||
|
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
continue
|
||||||
|
for call in ast.walk(fn):
|
||||||
|
if isinstance(call, ast.Call):
|
||||||
|
name = getattr(call.func, "attr", None) or getattr(call.func, "id", None)
|
||||||
|
if name in ("run_rule_arm", "_rules_via_lessons", "_rule_moment"):
|
||||||
|
callers.setdefault(name, set()).add(fn.name)
|
||||||
|
assert callers["run_rule_arm"] == {"_rule_moment"}
|
||||||
|
assert callers["_rules_via_lessons"] == {"_rule_moment"}
|
||||||
|
assert callers["_rule_moment"] == {
|
||||||
|
"build_prompt_rule_hint", "build_tool_rule_hint", "build_write_path_hint",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_the_source_is_ranked_and_registered():
|
def test_the_source_is_ranked_and_registered():
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import pytest
|
|||||||
|
|
||||||
from scribe.services import plugin_context as pc
|
from scribe.services import plugin_context as pc
|
||||||
from scribe.services import system_rulings as sr
|
from scribe.services import system_rulings as sr
|
||||||
|
from scribe.services.retrieval_pipeline import RuleResult
|
||||||
# Bound at import, before conftest's autouse stub replaces the module attribute.
|
# Bound at import, before conftest's autouse stub replaces the module attribute.
|
||||||
from scribe.services.system_rulings import rulings_for_paths as real_rulings_for_paths
|
from scribe.services.system_rulings import rulings_for_paths as real_rulings_for_paths
|
||||||
from tests.helpers import writepath_cfg
|
from tests.helpers import writepath_cfg
|
||||||
@@ -198,8 +199,8 @@ RULING_LINE = "> Rulings for `src/a.py` — the operator's decisions about A (Sy
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_the_tool_arm_leads_with_rulings_and_hands_back_what_it_showed():
|
async def test_the_tool_arm_leads_with_rulings_and_hands_back_what_it_showed():
|
||||||
rulings = AsyncMock(return_value={"lines": [RULING_LINE], "system_ids": [3]})
|
rulings = AsyncMock(return_value={"lines": [RULING_LINE], "system_ids": [3]})
|
||||||
with patch.object(pc, "_tool_rule_hint", AsyncMock(return_value={
|
with patch.object(pc, "_rule_moment", AsyncMock(return_value=RuleResult(
|
||||||
"context": "> a rule line", "rule_ids": [8], "checkpoint": {}})), \
|
lines=["> a rule line"], rule_ids=[8]))), \
|
||||||
patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())), \
|
patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())), \
|
||||||
patch.object(pc.system_rulings_svc, "rulings_for_paths", rulings):
|
patch.object(pc.system_rulings_svc, "rulings_for_paths", rulings):
|
||||||
out = await pc.build_tool_rule_hint(
|
out = await pc.build_tool_rule_hint(
|
||||||
@@ -215,8 +216,7 @@ async def test_the_tool_arm_leads_with_rulings_and_hands_back_what_it_showed():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_the_tool_arm_adds_no_key_when_no_ruling_was_shown():
|
async def test_the_tool_arm_adds_no_key_when_no_ruling_was_shown():
|
||||||
with patch.object(pc, "_tool_rule_hint", AsyncMock(return_value={
|
with patch.object(pc, "_rule_moment", AsyncMock(return_value=RuleResult())), \
|
||||||
"context": "", "rule_ids": [], "checkpoint": {}})), \
|
|
||||||
patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())):
|
patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())):
|
||||||
out = await pc.build_tool_rule_hint(1, "Bash", "ls", project_id=2)
|
out = await pc.build_tool_rule_hint(1, "Bash", "ls", project_id=2)
|
||||||
assert "ruling_system_ids" not in out
|
assert "ruling_system_ids" not in out
|
||||||
|
|||||||
Reference in New Issue
Block a user