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

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:
2026-09-19 00:46:20 -04:00
co-authored by Claude Opus 5
parent 31b478b7ac
commit 3075de19eb
5 changed files with 570 additions and 25 deletions
+34 -9
View File
@@ -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"