feat(lessons): soft links — a lesson and a rule arriving together in distinct situations are proposed as a link (milestone 440 step 3, #4637)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 30s

When one hook response puts a lesson and a rule in front of the reader, the
pair is recorded as evidence on a SUGGESTED lesson_rule_links row; once the
pair has arrived together in PROPOSE_SITUATIONS (3) distinct situations, the
next co-arrival carries one line asking the reader to judge it with
judge_lesson_link. Nothing about surfacing changes: a suggested link carries
no rule anywhere (that is #4633, confirmed links only).

- lesson_rules: co_surfaced (fail-open; only pairs the reader could confirm —
  a lesson they may write, a rule they own; judged pairs gather nothing; one
  proposal per response; PROPOSE_COOLDOWN 6h between asks), plus the pure
  counting rules: situation_key, add_evidence, proposal_due, evidence_summary.
  A situation is the prompt on /retrieve (word tokens, sorted and
  de-duplicated, so trivial rewordings count once) and the FILE on
  /prior-art (every edit to one file is one situation).
- rules_for_lessons shows a suggested link's evidence counts.
- plugin_context: build_autoinject_hint returns lesson_ids,
  build_prompt_rule_hint returns shown_rule_ids, build_write_path_hint records
  its own pair; routes/plugin /retrieve records the prompt pair. Shown lines,
  repeats included: relevance makes a co-arrival, not the session ledger.
- Evidence lives in the existing evidence column — no migration, and backup
  already carries it.
- Tests: counting rules (unit), the recorder against Postgres (bar, repeat,
  judged pairs, ownership, cooldown, evidence kept on confirm); conftest stubs
  co_surfaced for unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 14:30:58 -04:00
co-authored by Claude Opus 5.5
parent 95be2512ab
commit dbbab859ce
6 changed files with 430 additions and 7 deletions
+31 -1
View File
@@ -33,6 +33,7 @@ from scribe.services.embeddings import (
semantic_search_notes,
semantic_search_rules,
)
from scribe.services import lesson_rules as lesson_rules_svc
from scribe.services.lessons import LESSON_NOTE_TYPE
from scribe.services.note_usage import record_surfaced
from scribe.services.rule_usage import record_rule_surfaced
@@ -1275,7 +1276,15 @@ async def build_autoinject_hint(
project_id=project_id,
)
return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg}
# The lessons this menu put in front of the reader, repeats included — for
# the soft-link recorder (#4637), which pairs them with the rule arm's
# lines in the same response. Relevance, not the session ledger, is what
# makes a co-arrival, so a `seen` lesson counts.
lesson_ids = [int(n.id) for _s, n in kept if _record_kind(n) == LESSON_NOTE_TYPE]
return {
"context": "\n".join(lines), "note_ids": note_ids, "config": cfg,
"lesson_ids": lesson_ids,
}
async def _reserve_slot_for_preference(
@@ -1498,6 +1507,10 @@ async def build_prompt_rule_hint(
# (#3668). The slot's hit is surfaced under `preference_slot` by the
# helper, against that source's own row.
rule_ids = [rule.id for _score, rule in fresh]
# Every rule LINE, repeats and the reserved slot included — what the
# reader actually had in front of them, for the soft-link recorder
# (#4637). Distinct from `rule_ids`, which is telemetry's fresh-only cut.
out["shown_rule_ids"] = [rule.id for _score, rule in hits]
# RANKED, not ambient: this arm chose what it showed. The name is also
# in `rule_usage.RANKED_SOURCES`, and it has to be — a ranked source
@@ -2583,6 +2596,7 @@ async def build_write_path_hint(
#
# Fails open like every other arm: a rule hint must never break a write.
rule_ids: list[int] = []
shown_rule_ids: list[int] = []
checkpoint: dict = {}
try:
already = set(exclude_rule_ids or [])
@@ -2623,6 +2637,9 @@ async def build_write_path_hint(
# would inflate pull_through's denominator with a choice this arm never
# made. A reference is a RENDERING decision, not a retrieval outcome.
rule_ids.extend(rule.id for _score, rule in fresh)
# Every rule line, repeats included — what the soft-link recorder
# pairs with this response's lessons (#4637).
shown_rule_ids = [rule.id for _score, rule in kept]
# The stop, beside the lines rather than instead of them — see
# `checkpoint_for`. `kept` is passed, not `fresh`: whether a rule
# was named earlier this session says nothing about whether this
@@ -2691,6 +2708,19 @@ async def build_write_path_hint(
except Exception:
logger.debug("write-path rule arm failed", exc_info=True)
# 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.
shown_lessons = [
int(item["id"]) for _m, item in menu if item.get("kind") == LESSON_NOTE_TYPE
]
proposal = await lesson_rules_svc.co_surfaced(
user_id, shown_lessons, shown_rule_ids, arm="w", situation=path,
project_id=project_id or None,
)
if proposal:
lines.append(proposal)
return {
"context": "\n".join(lines),
"note_ids": note_ids,