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:
@@ -28,6 +28,7 @@ from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.routes.utils import not_found, parse_pagination
|
||||
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
|
||||
@@ -136,6 +137,12 @@ async def create_lesson_route():
|
||||
|
||||
project_id = data.get("project_id") or None
|
||||
learned_from = data.get("learned_from") or []
|
||||
# The rules this lesson is an instance of (milestone 440). Validated before
|
||||
# anything is written, as the MCP door does, so a bad id saves nothing.
|
||||
try:
|
||||
linked = await lesson_rules_svc.require_rules(uid, data.get("rule_ids") or [])
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
# The same near-duplicate gate the MCP create path applies. Two lessons
|
||||
# under one trigger compete in a single ranked list for one reserved slot,
|
||||
@@ -165,10 +172,13 @@ async def create_lesson_route():
|
||||
)
|
||||
if data.get("system_ids") is not None:
|
||||
await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
|
||||
if linked:
|
||||
await lesson_rules_svc.set_lesson_rules(uid, note.id, linked)
|
||||
out = lessons_svc.lesson_to_dict(note)
|
||||
out["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
await lesson_rules_svc.attach_lesson_rules(uid, [out])
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@@ -194,6 +204,7 @@ async def get_lesson_route(lesson_id: int):
|
||||
)
|
||||
out.update(await describe_provenance(uid, note))
|
||||
await attach_usage([out])
|
||||
await lesson_rules_svc.attach_lesson_rules(uid, [out])
|
||||
# Opening the detail view IS a pull — the operator chose to look. Tagged
|
||||
# apart from the MCP sources so "an agent was handed it" and "a human read
|
||||
# it" stay distinguishable; they mean different things for pruning (#2085).
|
||||
@@ -233,9 +244,20 @@ async def update_lesson_route(lesson_id: int):
|
||||
),
|
||||
}), 400
|
||||
|
||||
linked = None
|
||||
if data.get("rule_ids") is not None:
|
||||
try:
|
||||
linked = await lesson_rules_svc.require_rules(uid, data["rule_ids"])
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
updated = await lessons_svc.update_lesson(owner_uid, lesson_id, **kwargs)
|
||||
if updated is None:
|
||||
return not_found("Lesson")
|
||||
if linked is not None:
|
||||
# The CALLER, for the reason system_ids below uses it: the rules named
|
||||
# must be ones the person making the edit can read.
|
||||
await lesson_rules_svc.set_lesson_rules(uid, lesson_id, linked)
|
||||
if data.get("system_ids") is not None:
|
||||
# The CALLER, not owner_uid (#4249). `set_record_systems` runs its own
|
||||
# `can_write_note` and links only Systems the acting user can read;
|
||||
@@ -249,9 +271,28 @@ async def update_lesson_route(lesson_id: int):
|
||||
s.to_dict()
|
||||
for s in await systems_svc.list_record_systems(owner_uid, lesson_id)
|
||||
]
|
||||
await lesson_rules_svc.attach_lesson_rules(uid, [out])
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@lessons_bp.route("/<int:lesson_id>/rules/<int:rule_id>", methods=["PUT"])
|
||||
@login_required
|
||||
async def judge_lesson_link_route(lesson_id: int, rule_id: int):
|
||||
"""Confirm or reject one lesson→rule link — the REST twin of the MCP
|
||||
`judge_lesson_link`. Body: {"verdict": "confirm" | "reject", "note": "…"}."""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
try:
|
||||
link = await lesson_rules_svc.judge_link(
|
||||
uid, lesson_id, rule_id, data.get("verdict", ""), data.get("note", ""),
|
||||
)
|
||||
except PermissionError:
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(link)
|
||||
|
||||
|
||||
@lessons_bp.route("/<int:lesson_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_lesson_route(lesson_id: int):
|
||||
|
||||
Reference in New Issue
Block a user