feat(rules): a rule surfaces through the lessons confirmed as its instances (milestone 440 step 4, #4633)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 55s
CI & Build / Python tests (push) Failing after 1m16s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 55s
CI & Build / Python tests (push) Failing after 1m16s
CI & Build / Build & push image (push) Skipped
On all three rule arms (prompt_rule, pre_tool_rule, write_path_rule), after the direct match, a lesson matching the moment at the notes menu's own bar brings the rule(s) it is CONFIRMED to be an instance of, rendered in rule voice with "Reached through lesson #N “…”, a recorded instance of it." - Confirmed links only (lesson_rules.confirmed_lessons / confirmed_rules_in_scope). A suggested link carrying its rule would manufacture the co-arrival #4637 counts and prove itself. - Scope kept: a lesson surfaces everywhere, but the rule it brings must be global or this project's own — milestone 414's boundary, not reopened through a side door. - Suppression is by RULE: anything the direct band named or the session ledger holds is skipped, whichever lesson reached it. - Its own slot (VIA_LESSON_LIMIT = 1), not a rule slot — a stated default, since the plan's "decide by measurement" has nothing to measure until links are confirmed (#4632). Its own source, rule_via_lesson: registered, RANKED for pull-through, logged whenever it searches. - Nothing searched at all while no lesson carries a confirmed link, which is every install until one is judged. - build_prompt_rule_hint / build_tool_rule_hint are now thin wrappers over the direct arms (_prompt_rule_hint / _tool_rule_hint); the arm leaves its query in `_via_query` only when it ran, so a disabled arm or a blank prompt brings no rule in either, and the key never leaves the server. - Via-lesson rules are not fed to the #4637 co-surfacing recorder: only direct matches are evidence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""A rule reached through its lessons (milestone 440, #4633).
|
||||
|
||||
The step runs after each of the three rule arms. These pin, with the database
|
||||
and the embedder stubbed: that nothing is searched when no lesson carries a
|
||||
confirmed link; that a matching linked lesson brings its rule in rule voice,
|
||||
naming the lesson; that suppression is by RULE; that the slot holds one line;
|
||||
and that the call is logged under its own source. That a SUGGESTED link never
|
||||
expands — the soft link proving itself — is pinned against Postgres in
|
||||
tests/test_integration_lesson_rule_links.py, where the state filter lives.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services import plugin_context as pc
|
||||
from scribe.services import rule_usage
|
||||
from scribe.services.retrieval_registry import POINTS
|
||||
|
||||
# Bound before conftest's autouse stub replaces the module attribute.
|
||||
_REAL = pc._rules_via_lessons
|
||||
|
||||
|
||||
def _lesson(lid=41, title="An overrun run usually failed early"):
|
||||
return SimpleNamespace(
|
||||
id=lid, title=title, note_type="lesson", body="",
|
||||
data={"what": title, "when_to_apply": "a CI run overran"},
|
||||
)
|
||||
|
||||
|
||||
def _rule(rid, title="Read the job log first", kind="rule"):
|
||||
return SimpleNamespace(id=rid, title=title, kind=kind, when_to_apply="a run overran")
|
||||
|
||||
|
||||
def _stubs(*, confirmed, found, by_lesson):
|
||||
log, surfaced = MagicMock(), MagicMock()
|
||||
stack = [
|
||||
patch.object(pc.lesson_rules_svc, "confirmed_lessons", AsyncMock(return_value=confirmed)),
|
||||
patch.object(pc.lesson_rules_svc, "confirmed_rules_in_scope", AsyncMock(return_value=by_lesson)),
|
||||
patch.object(pc, "get_autoinject_config", AsyncMock(return_value={"threshold": 0.5})),
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=found)),
|
||||
patch.object(pc, "record_retrieval", log),
|
||||
patch.object(pc, "record_rule_surfaced", surfaced),
|
||||
]
|
||||
return stack, log, surfaced
|
||||
|
||||
|
||||
async def _run(stack, **kw):
|
||||
for p in stack:
|
||||
p.start()
|
||||
try:
|
||||
return await _REAL(1, "the CI run is still going", project_id=2,
|
||||
skip=kw.get("skip", set()), held=set(), where="to this request")
|
||||
finally:
|
||||
for p in stack:
|
||||
p.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_confirmed_link_means_no_search_and_no_row():
|
||||
search = AsyncMock()
|
||||
stack, log, _ = _stubs(confirmed=set(), found=[], by_lesson={})
|
||||
stack[3] = patch.object(pc, "semantic_search_notes", search)
|
||||
assert await _run(stack) == ([], [])
|
||||
search.assert_not_awaited()
|
||||
log.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_matching_linked_lesson_brings_its_rule_in_rule_voice():
|
||||
stack, log, surfaced = _stubs(
|
||||
confirmed={41}, found=[(0.71, _lesson())], by_lesson={41: [_rule(7)]},
|
||||
)
|
||||
lines, ids = await _run(stack)
|
||||
assert ids == [7]
|
||||
assert lines[0].startswith("Standing rule")
|
||||
assert "Reached through lesson #41" in lines[0]
|
||||
assert log.call_args.kwargs["source"] == "rule_via_lesson"
|
||||
assert surfaced.call_args.kwargs == {"user_id": 1, "rule_ids": [7], "source": "rule_via_lesson"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_lesson_without_a_confirmed_link_carries_nothing():
|
||||
"""The search found a lesson, but it is not in the confirmed set — a
|
||||
suggested or unlinked lesson brings no rule."""
|
||||
stack, log, surfaced = _stubs(
|
||||
confirmed={99}, found=[(0.9, _lesson(41))], by_lesson={},
|
||||
)
|
||||
assert await _run(stack) == ([], [])
|
||||
surfaced.assert_not_called()
|
||||
assert log.call_args.kwargs["results"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppression_is_by_rule_whichever_lesson_reached_it():
|
||||
stack, log, surfaced = _stubs(
|
||||
confirmed={41}, found=[(0.71, _lesson())], by_lesson={41: [_rule(7)]},
|
||||
)
|
||||
assert await _run(stack, skip={7}) == ([], [])
|
||||
assert log.call_args.kwargs["suppressed"] == 1
|
||||
surfaced.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_slot_holds_one_line():
|
||||
stack, _log, _ = _stubs(
|
||||
confirmed={41, 42},
|
||||
found=[(0.8, _lesson(41)), (0.7, _lesson(42, "Another"))],
|
||||
by_lesson={41: [_rule(7), _rule(8)], 42: [_rule(9)]},
|
||||
)
|
||||
lines, ids = await _run(stack)
|
||||
assert ids == [7] and len(lines) == pc.VIA_LESSON_LIMIT == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failure_brings_no_rule_and_raises_nothing():
|
||||
stack, _log, _ = _stubs(confirmed={41}, found=[], by_lesson={})
|
||||
stack[0] = patch.object(pc.lesson_rules_svc, "confirmed_lessons",
|
||||
AsyncMock(side_effect=RuntimeError("db down")))
|
||||
assert await _run(stack) == ([], [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_step_runs_only_when_the_arm_ran_and_never_leaks_its_key():
|
||||
step = AsyncMock(return_value=(["Standing rule … Reached through lesson #41"], [7]))
|
||||
with patch.object(pc, "_rules_via_lessons", step):
|
||||
idle = await pc._add_rules_via_lessons(
|
||||
1, {"context": "", "rule_ids": []}, project_id=2,
|
||||
exclude_rule_ids=[3], held_rule_ids=[], where="here",
|
||||
)
|
||||
ran = await pc._add_rules_via_lessons(
|
||||
1, {"context": "direct line", "rule_ids": [5], "_via_query": "q"},
|
||||
project_id=2, exclude_rule_ids=[3], held_rule_ids=[], where="here",
|
||||
)
|
||||
assert idle == {"context": "", "rule_ids": []}
|
||||
assert step.await_count == 1
|
||||
assert step.await_args.kwargs["skip"] == {3, 5}
|
||||
assert "_via_query" not in ran
|
||||
assert ran["rule_ids"] == [5, 7]
|
||||
assert ran["context"].startswith("direct line\n")
|
||||
|
||||
|
||||
def test_the_source_is_ranked_and_registered():
|
||||
assert "rule_via_lesson" in rule_usage.RANKED_SOURCES
|
||||
assert "rule_via_lesson" in POINTS
|
||||
Reference in New Issue
Block a user