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