feat(lessons): a lesson names the rule it is an instance of — lesson_rule_links (milestone 440 step 1, #4630)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m13s
CI & Build / Build & push image (push) Skipped

The link between a lesson (one concrete situation) and the rule that governs
it, with the operator's soft-then-hard design built into its state:
suggested while evidence accumulates, confirmed or rejected once judged. Only
confirmed will carry a rule in retrieval (#4633); rejected is kept so the pair
is never proposed again.

- models/lesson_rule_link.py + migration 0111: one row per (lesson, rule),
  CASCADE on both ends, indexed both ways, CHECK on state (rule 36), evidence
  JSONB and judged_at.
- services/lesson_rules.py: require_rules (validated before any write, so
  a bad id leaves nothing half-linked), set_lesson_rules (set-semantics;
  a dropped rule becomes rejected, not forgotten), judge_link, and the two
  reads. ACL: write on the lesson (share-aware), ownership of the rule; a
  reader sees only rules they own. Decorations are fail-open (#4286).
- MCP: create_lesson / update_lesson take rule_ids; get/create/update return
  `rules`; new judge_lesson_link tool. REST: the same on /api/lessons plus
  PUT /api/lessons/<id>/rules/<rule_id>. Rules: rule_detail carries `lessons`.
- Backup v18: export (full and user-scoped, both ends in scope), builder,
  importer; both column guards register the table.
- Tests: integration (states, set-semantics, judge, ACL all-or-nothing,
  cascade both ways, CHECK, one row per pair); unit (door wiring, judge
  registered, migration/model state agreement, backup skip and unjudged
  stays unjudged). conftest stubs the decorations for unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 12:38:02 -04:00
co-authored by Claude Opus 5.5
parent 2e4c2d9493
commit 41e4fbaba1
12 changed files with 875 additions and 6 deletions
+49 -2
View File
@@ -13,6 +13,7 @@ from scribe.mcp._context import current_user_id
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import knowledge as knowledge_svc
from scribe.services import lesson_rules as lesson_rules_svc
from scribe.services import lessons as lessons_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
@@ -89,6 +90,7 @@ async def create_lesson(
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
rule_ids: list[int] | None = None,
force: bool = False,
) -> dict:
"""Record something you LEARNED, so a later session meets it at the moment
@@ -145,6 +147,12 @@ async def create_lesson(
reach: a lesson is retrievable from every project (that is the
point of the kind). 0 = none.
system_ids: Systems (subsystems/areas) to file it under.
rule_ids: The rule(s) or preference(s) this lesson is an instance of —
the binding choice its situation falls under. A lesson never
becomes a rule; it points at the one that governs it, and the rule
is then reachable through the situation the lesson describes
(milestone 440). Naming a rule here confirms the link. Leave it
empty when no rule governs this situation.
force: Create even if a near-duplicate exists.
Returns the created lesson. On a near-duplicate, returns the existing id
@@ -161,6 +169,9 @@ async def create_lesson(
)
sources = lessons_svc.normalize_sources(learned_from)
# Validated before anything is written, so a lesson naming a rule the
# caller cannot read fails whole rather than saving half-linked.
linked = await lesson_rules_svc.require_rules(uid, rule_ids)
title, body = lessons_svc.lesson_document(
what, when_to_apply, insight, sources,
)
@@ -179,8 +190,11 @@ async def create_lesson(
)
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
if linked:
await lesson_rules_svc.set_lesson_rules(uid, note.id, linked)
data = _to_dict(note)
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await lesson_rules_svc.attach_lesson_rules(uid, [data])
return data
@@ -214,6 +228,7 @@ async def get_lesson(lesson_id: int, project_id: int = 0) -> dict:
# one that was true when it asked — otherwise every first read of a lesson
# reports a pull that is its own.
await attach_usage([out])
await lesson_rules_svc.attach_lesson_rules(uid, [out])
record_pulled(
user_id=uid, note_id=int(note.id),
source="mcp_get_lesson", project_id=project_id,
@@ -229,6 +244,7 @@ async def update_lesson(
learned_from: list[int] | None = None,
tags: list[str] | None = None,
system_ids: list[int] | None = None,
rule_ids: list[int] | None = None,
) -> dict:
"""Update a lesson. Empty fields are left unchanged.
@@ -258,8 +274,16 @@ async def update_lesson(
when that argument gets dropped (#4249). A System tag is how
`list_system_records` gathers an area's pile, so an untagged
lesson is reachable by search and by nothing else.
rule_ids: Replace the rule(s) this lesson is an instance of. None
leaves unchanged; pass the FULL list. A rule that was linked and
is left out is recorded as REJECTED — "not an instance of this
one" — so the pair is not proposed again; `[]` rejects them all.
"""
uid = current_user_id()
linked = (
await lesson_rules_svc.require_rules(uid, rule_ids)
if rule_ids is not None else None
)
note = await lessons_svc.update_lesson(
uid, lesson_id,
what=what or None,
@@ -276,7 +300,30 @@ async def update_lesson(
if system_ids is not None:
await systems_svc.set_record_systems(uid, lesson_id, system_ids)
note = await lessons_svc.get_lesson(uid, lesson_id) or note
return _to_dict(note)
if linked is not None:
await lesson_rules_svc.set_lesson_rules(uid, lesson_id, linked)
out = _to_dict(note)
await lesson_rules_svc.attach_lesson_rules(uid, [out])
return out
async def judge_lesson_link(
lesson_id: int, rule_id: int, verdict: str, note: str = "",
) -> dict:
"""Say whether a lesson is an instance of a rule: `verdict` is "confirm"
or "reject".
Reach for this when Scribe proposes a pair — a lesson and a rule that keep
arriving together in different situations — or whenever you are reading a
lesson and recognise the rule it falls under. Confirming makes the rule
reachable through the situation the lesson describes; rejecting records
that it is not, so the pair is not proposed again. `note` is the why, and
the next reader judges the link by it.
Returns the link: {lesson_id, rule_id, state, note, evidence, judged_at}.
"""
uid = current_user_id()
return await lesson_rules_svc.judge_link(uid, lesson_id, rule_id, verdict, note)
async def delete_lesson(lesson_id: int) -> dict:
@@ -312,5 +359,5 @@ async def delete_lesson(lesson_id: int) -> dict:
def register(mcp) -> None:
for fn in (list_lessons, create_lesson, get_lesson, update_lesson,
delete_lesson):
delete_lesson, judge_lesson_link):
mcp.tool(name=fn.__name__)(fn)