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:
@@ -1390,6 +1390,126 @@ async def _reserve_slot_for_preference(
|
||||
return hits + slot, slot_id
|
||||
|
||||
|
||||
# ── A rule reached through its lessons (milestone 440, #4633) ──────────────
|
||||
#
|
||||
# A rule's own document is written in the rule's words, which are general by
|
||||
# design; the situations that keep proving it are often closer to what a
|
||||
# session is actually doing. A lesson JUDGED to be an instance of a rule (a
|
||||
# CONFIRMED link) carries that situation, so a lesson matching the moment
|
||||
# brings its rule along — in rule voice, naming the lesson that reached it.
|
||||
#
|
||||
# ITS OWN SLOT, NOT A RULE SLOT, as a stated default. The plan asked for this
|
||||
# to be settled by measurement, and there is nothing to measure until links
|
||||
# are confirmed (#4632). Until then the asymmetry decides it: a via-lesson
|
||||
# line that took a rule slot could push out a rule the ranker matched
|
||||
# DIRECTLY, a stronger claim displaced by a weaker one, while an extra line
|
||||
# costs one line. `rule_via_lesson` is logged as its own source so the
|
||||
# question can be answered from data later (#4636).
|
||||
VIA_LESSON_LIMIT = 1
|
||||
# Lessons fetched before keeping only the linked ones. The search cannot be
|
||||
# told "linked lessons only", so it overfetches and filters; on a corpus where
|
||||
# most lessons are unlinked, a fetch of one would almost always be spent on a
|
||||
# lesson that carries nothing.
|
||||
_VIA_LESSON_OVERFETCH = 10
|
||||
|
||||
|
||||
async def _rules_via_lessons(
|
||||
user_id: int, query: str, *, project_id: int | None, skip: set[int],
|
||||
held: set[int], where: str,
|
||||
) -> tuple[list[str], list[int]]:
|
||||
"""Rule lines reached through a matching lesson's CONFIRMED links.
|
||||
|
||||
The lesson bar is the notes menu's own threshold — the bar a lesson has to
|
||||
clear to be shown at all — so a lesson too weak to surface cannot carry a
|
||||
rule in. `skip` is every rule this response already names plus the
|
||||
session ledger: suppression applies to the RULE, whichever lesson reached
|
||||
it. Returns (lines, rule ids shown). Fails open, like every arm.
|
||||
"""
|
||||
try:
|
||||
confirmed = await lesson_rules_svc.confirmed_lessons(user_id)
|
||||
if not confirmed:
|
||||
return [], []
|
||||
bar = (await get_autoinject_config(user_id))["threshold"]
|
||||
t0 = time.perf_counter()
|
||||
_rep: dict = {}
|
||||
found = await semantic_search_notes(
|
||||
user_id, query, limit=_VIA_LESSON_OVERFETCH, threshold=bar,
|
||||
project_id=project_id, note_type=(LESSON_NOTE_TYPE,),
|
||||
include_global_kinds=True, scope="browse", report=_rep,
|
||||
)
|
||||
matched = [(s, n) for s, n in found if int(n.id) in confirmed]
|
||||
by_lesson = await lesson_rules_svc.confirmed_rules_in_scope(
|
||||
user_id, [int(n.id) for _s, n in matched], project_id,
|
||||
)
|
||||
candidates = [
|
||||
(s, rule, n) for s, n in matched for rule in by_lesson.get(int(n.id), [])
|
||||
]
|
||||
chosen: list = []
|
||||
taken: set[int] = set(skip)
|
||||
for s, rule, n in candidates:
|
||||
if rule.id in taken:
|
||||
continue
|
||||
taken.add(rule.id)
|
||||
chosen.append((s, rule, n))
|
||||
if len(chosen) >= VIA_LESSON_LIMIT:
|
||||
break
|
||||
# Logged whenever a search ran, results or not — the #3497 guard. The
|
||||
# score is the LESSON's, because the lesson is what was matched; the
|
||||
# best-available id is left off for the same reason, since the row's
|
||||
# results are rules and an id beside them would read as a rule id.
|
||||
record_retrieval(
|
||||
user_id=user_id, source="rule_via_lesson", query=query,
|
||||
threshold=bar, limit=VIA_LESSON_LIMIT, project_id=project_id,
|
||||
is_task=None, results=[(s, rule) for s, rule, _n in chosen],
|
||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||
best_available=_rep.get("best_available_score"),
|
||||
searched=bool(_rep.get("searched", True)),
|
||||
suppressed=len({r.id for _s, r, _n in candidates}) - len(chosen),
|
||||
)
|
||||
if not chosen:
|
||||
return [], []
|
||||
rule_ids = [rule.id for _s, rule, _n in chosen]
|
||||
record_rule_surfaced(user_id=user_id, rule_ids=rule_ids, source="rule_via_lesson")
|
||||
lines = [
|
||||
_rule_hint_line(rule, where=where, seen=False, held=rule.id in held)
|
||||
+ f" Reached through lesson #{n.id} "
|
||||
+ f"\u201c{_menu_name(n.title, n.note_type, n.data, n.body)}\u201d, "
|
||||
+ "a recorded instance of it."
|
||||
for _s, rule, n in chosen
|
||||
]
|
||||
return lines, rule_ids
|
||||
except Exception:
|
||||
logger.debug("rule-via-lesson arm failed", exc_info=True)
|
||||
return [], []
|
||||
|
||||
|
||||
async def _add_rules_via_lessons(
|
||||
user_id: int, out: dict, *, project_id: int, exclude_rule_ids, held_rule_ids,
|
||||
where: str,
|
||||
) -> dict:
|
||||
"""Run the via-lesson step after a direct rule arm, on the query that arm
|
||||
actually searched with.
|
||||
|
||||
The arm leaves `_via_query` in its payload only when it RAN — enabled, and
|
||||
with something to search — so an arm the operator switched off, or a
|
||||
blank prompt, brings no rule in through a side door either. The key is
|
||||
popped here; it never leaves the server.
|
||||
"""
|
||||
query = out.pop("_via_query", "")
|
||||
if not query:
|
||||
return out
|
||||
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:
|
||||
out["context"] = "\n".join(c for c in (out.get("context") or "", *lines) if c)
|
||||
out["rule_ids"] = list(out.get("rule_ids") or []) + ids
|
||||
return out
|
||||
|
||||
|
||||
async def build_prompt_rule_hint(
|
||||
user_id: int,
|
||||
query: str,
|
||||
@@ -1398,6 +1518,28 @@ async def build_prompt_rule_hint(
|
||||
exclude_rule_ids: list[int] | None = None,
|
||||
held_rule_ids: list[int] | None = None,
|
||||
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:
|
||||
"""Rules and preferences that may apply to what the operator just asked.
|
||||
|
||||
@@ -1436,6 +1578,9 @@ async def build_prompt_rule_hint(
|
||||
# names it — and "yes, go ahead" names nothing a trigger can match, while
|
||||
# the reply it answers ("commit this to dev and push") does.
|
||||
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:
|
||||
threshold = await floor_for(user_id, "prompt_rule")
|
||||
@@ -2708,6 +2853,17 @@ async def build_write_path_hint(
|
||||
except Exception:
|
||||
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
|
||||
# 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.
|
||||
@@ -2745,6 +2901,28 @@ async def build_tool_rule_hint(
|
||||
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 — matched
|
||||
directly (`_tool_rule_hint`, where the design is written), then reached
|
||||
through a linked lesson (`_rules_via_lessons`, #4633)."""
|
||||
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,
|
||||
)
|
||||
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=f"to this {tool_name} call",
|
||||
)
|
||||
|
||||
|
||||
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).
|
||||
|
||||
@@ -2795,6 +2973,8 @@ async def build_tool_rule_hint(
|
||||
# embedding window, so it is bounded — the verb and its target sit at
|
||||
# the front, which is the part a rule is about.
|
||||
query = command[:_TOOL_QUERY_CHARS]
|
||||
# For the via-lesson step (#4633) — set only once the arm is enabled.
|
||||
out["_via_query"] = query
|
||||
|
||||
t0 = time.perf_counter()
|
||||
_rep_ptr: dict = {}
|
||||
|
||||
Reference in New Issue
Block a user