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
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user