feat(lessons): a lesson reaches the moment it applies, and says it binds nothing (#3732)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 33s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 33s
WHICH ARM — the two note arms, and no new one. `write_path` filters kinds, so a lesson was not outranked there but unreachable, which is #3702's shape: an arm that never had the candidate reports a healthy bar. It now asks for lessons alongside snippets and issues. The founding example of the kind is a lesson about a code shape, and this is the arm that fires when code is written. `auto_inject` does not filter kinds, so lessons were already candidates — but scoped to the bound project, which for a kind whose whole claim is that it transfers is the same silence. Both arms now pass `include_global_kinds` (#3730). WHOSE BUDGET — a reserved slot in the prompt menu, none on the write path. The step's premise needs a correction: the notes menu and the rule hints are separate functions with separate budgets, so a line reserved here displaces a note, never a rule. (`RULEHINT_LIMIT` is also 5, not 1, since #4102 made it a default rather than a cap.) The trade taken: a note crowded out is a lost convenience and a rule crowded out still fires at an act arm, but a lesson crowded out is the feature failing — a lesson exists only to be met at the moment it applies, so the arm IS its delivery and the loss is total and silent. That is `preference_slot`'s argument, and the rarity is `reuse_slot`'s. It buys position, never a lower bar, and it EXTENDS rather than evicting: a displaced hit sits in the general search's own log row, and evicting it would make two tables disagree about one call (#3668, #379). No slot on the write path: that arm fires before every Write and Edit, where a guaranteed extra line is a guaranteed extra interruption, and its field is already just snippets, issues and lessons rather than the whole corpus. `lesson_slot` logs its own retrieval and its own surfacing from the first deploy, and the general contest stays open to the kind — otherwise "the slot earns its line" would be true by construction. THE VOICE — "they don't always have to be followed". The menu's register is already the non-binding one. What it lacked is that a lesson reads as one more title in a list of material when it is advice someone paid for. One clause, in the header, only when a lesson is on the menu: weigh it, use your judgement, it is not a rule and binds nothing. It deliberately does not borrow the rule arms' "before deciding it does not apply", and a guard asserts that phrase never appears. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from scribe.services import plugin_context as pc_module
|
||||
from scribe.services import retrieval_surfaces as rs
|
||||
from scribe.services.lessons import LESSON_NOTE_TYPE
|
||||
from tests.helpers import fake_note, writepath_cfg
|
||||
|
||||
|
||||
@@ -83,7 +85,7 @@ async def test_build_autoinject_hint_titles_only_with_margin_gate():
|
||||
# the one unlogged retrieval on this path — the hit it displaced was in
|
||||
# retrieval_logs, the query that displaced it was not (#2463).
|
||||
sources = [c.kwargs["source"] for c in rec.call_args_list]
|
||||
assert sources == ["auto_inject", "reuse_slot"]
|
||||
assert sources == ["auto_inject", "reuse_slot", "lesson_slot"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -331,14 +333,34 @@ async def test_build_session_context_caps_length():
|
||||
_CFG = {"enabled": True, "threshold": 0.55, "top_k": 3}
|
||||
|
||||
|
||||
async def _autoinject(main_hits, reuse_hits, cfg=None):
|
||||
"""Run build_autoinject_hint with the two semantic queries stubbed in order:
|
||||
the unscoped pool first, then the reserved reuse query."""
|
||||
def _asked_for_reuse(calls: list[dict]) -> bool:
|
||||
"""Did the reuse slot issue its reserved query on this run?"""
|
||||
return any(
|
||||
tuple(c.get("note_type") or ()) == pc_module._REUSE_KINDS for c in calls
|
||||
)
|
||||
|
||||
|
||||
async def _autoinject(main_hits, reuse_hits, cfg=None, lesson_hits=None):
|
||||
"""Run build_autoinject_hint with each semantic query stubbed by the kinds
|
||||
it asks for: the unscoped pool, the reserved reuse query, and the reserved
|
||||
lesson query (milestone 385 step 5).
|
||||
|
||||
ROUTED ON THE REQUESTED KINDS, not on call order, and that is the point of
|
||||
the helper. Order-keyed stubbing was fine while there was one reserved
|
||||
slot; with two it makes every test in this section depend on which slot
|
||||
runs first, so adding a third would silently hand one slot another's
|
||||
candidate list and the tests would still pass.
|
||||
"""
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake_search(*_a, **kw):
|
||||
calls.append(kw)
|
||||
return reuse_hits if kw.get("note_type") else main_hits
|
||||
kinds = kw.get("note_type") or ()
|
||||
if LESSON_NOTE_TYPE in kinds:
|
||||
return lesson_hits or []
|
||||
if kinds:
|
||||
return reuse_hits
|
||||
return main_hits
|
||||
|
||||
with patch("scribe.services.plugin_context.get_autoinject_config",
|
||||
AsyncMock(return_value=dict(cfg or _CFG))), \
|
||||
@@ -382,7 +404,10 @@ async def test_the_reserved_query_is_skipped_when_a_snippet_already_won():
|
||||
|
||||
out, calls = await _autoinject(main, [])
|
||||
|
||||
assert len(calls) == 1 # reserved query never ran
|
||||
# WHICH query ran, not how many. A count here is a claim about every
|
||||
# reserved slot on this path at once, so it fails the day an unrelated one
|
||||
# is added — and the thing being tested is that THIS slot stood down.
|
||||
assert not _asked_for_reuse(calls)
|
||||
assert out["note_ids"] == [9, 1]
|
||||
|
||||
|
||||
@@ -396,7 +421,7 @@ async def test_a_weak_snippet_does_not_buy_the_slot():
|
||||
out, calls = await _autoinject(main, []) # threshold returned nothing
|
||||
|
||||
assert out["note_ids"] == [1]
|
||||
assert len(calls) == 2 # it asked, and got nothing
|
||||
assert _asked_for_reuse(calls) # it asked, and got nothing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -425,7 +450,7 @@ async def test_a_process_counts_as_reuse_too():
|
||||
# …and one already on the menu suppresses the reserved query.
|
||||
out2, calls2 = await _autoinject(
|
||||
[(0.70, fake_note(id=8, title="DRY pass process", user_id=1, note_type="process"))], [])
|
||||
assert len(calls2) == 1
|
||||
assert not _asked_for_reuse(calls2)
|
||||
|
||||
|
||||
# --- write-path widened beyond snippets (#2246, the mirror half) -------------
|
||||
@@ -456,7 +481,7 @@ async def test_write_path_semantic_arm_asks_for_experience_not_just_snippets():
|
||||
)
|
||||
|
||||
kw = search.await_args.kwargs
|
||||
assert kw["note_type"] == ("snippet", "note")
|
||||
assert kw["note_type"] == ("snippet", "note", LESSON_NOTE_TYPE)
|
||||
# An open to-do resembling the code answers nothing; an ISSUE carries a root
|
||||
# cause and a NOTE carries durable knowledge. Only the todo is excluded.
|
||||
assert kw["task_kind"] == "issue"
|
||||
|
||||
Reference in New Issue
Block a user