feat(lessons): judged when written — a new lesson is offered its rules, and "no rule fits" is an answer (milestone 440 step 2, #4631)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m15s
CI & Build / Build & push image (push) Skipped

"Which rule is this lesson an instance of?" now has three recorded answers:
a rule named (a confirmed link, #4630), no rule fits (new), or unjudged.

- Model + migration 0112: lesson_no_rule (lesson_id PK, CASCADE from the
  note; why; judged_at). A table rather than a key in notes.data, because
  that mirror is re-composed from the body on every edit and would erase it.
- Service (lesson_rules): set_no_rule rejects any confirmed link with the
  reason; a confirmation (set_lesson_rules or judge_link) deletes the answer;
  require_one_answer refuses both answers in one call before any write;
  judgments_for_lessons + attach_lesson_rules add rule_judgment (and no_rule)
  to every lesson payload; list_unjudged lists the open ones; rule_candidates
  searches rules with the lesson's claim + trigger at the explicit-search bar,
  None when the search could not run.
- MCP: create_lesson/update_lesson take no_rule; an unanswered create returns
  rule_candidates, rule_judgment and a rule_hint; list_lessons(unjudged=true).
- REST: the same on POST/PATCH /api/lessons and GET ?unjudged=1; create
  returns rule_candidates.
- Backup v19: a lesson_no_rule section, export (full and per-user) and import.
- Guidance: create_lesson docstring, writing-records.md in using-scribe (owner,
  pinned in test_guidance_ownership), create_rule docstring on linking the
  lessons a new rule governs. Plugin version minted.
- Tests: door units, integration for the three states, the rejection reason,
  scoping, cascade; backup registries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 13:25:27 -04:00
co-authored by Claude Opus 5.5
parent c8393975c3
commit f7d8dc2e55
15 changed files with 696 additions and 37 deletions
+39 -14
View File
@@ -63,16 +63,25 @@ async def list_lessons_route():
project_id = None
limit, offset = parse_pagination(default_limit=24, max_limit=100)
items, total = await knowledge_svc.query_knowledge(
user_id=uid,
note_type=lessons_svc.LESSON_NOTE_TYPE,
tags=[tag] if tag else [],
sort="modified",
q=q,
limit=limit,
offset=offset,
project_id=project_id,
)
# The lessons nobody has answered "which rule?" for (#4631). A listing,
# so it does not combine with a search — the MCP door says the same.
if request.args.get("unjudged") in ("1", "true"):
if q:
return jsonify({"error": "unjudged is a listing; drop q"}), 400
items, total = await lesson_rules_svc.list_unjudged(
uid, tag=tag, project_id=project_id, limit=limit, offset=offset,
)
else:
items, total = await knowledge_svc.query_knowledge(
user_id=uid,
note_type=lessons_svc.LESSON_NOTE_TYPE,
tags=[tag] if tag else [],
sort="modified",
q=q,
limit=limit,
offset=offset,
project_id=project_id,
)
items = await label_shared_items(uid, items)
# One aggregate for the whole page — a per-row lookup would be N+1 by
# construction. Every row gets the key, zero-filled, so the UI renders
@@ -139,8 +148,10 @@ async def create_lesson_route():
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.
no_rule = (data.get("no_rule") or "").strip()
try:
linked = await lesson_rules_svc.require_rules(uid, data.get("rule_ids") or [])
lesson_rules_svc.require_one_answer(linked, no_rule)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@@ -174,11 +185,21 @@ async def create_lesson_route():
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)
elif no_rule:
await lesson_rules_svc.set_no_rule(uid, note.id, no_rule)
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])
# The same offer the MCP door makes, so the form can show the rules to
# judge against while the writer still has the situation in mind.
if not linked and not no_rule:
candidates = await lesson_rules_svc.rule_candidates(
uid, what, when_to_apply, project_id,
)
if candidates is not None:
out["rule_candidates"] = candidates
return jsonify(out), 201
@@ -245,11 +266,13 @@ async def update_lesson_route(lesson_id: int):
}), 400
linked = None
if data.get("rule_ids") is not None:
try:
no_rule = (data.get("no_rule") or "").strip()
try:
if data.get("rule_ids") is not None:
linked = await lesson_rules_svc.require_rules(uid, data["rule_ids"])
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
lesson_rules_svc.require_one_answer(linked, no_rule)
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:
@@ -258,6 +281,8 @@ async def update_lesson_route(lesson_id: int):
# 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 no_rule:
await lesson_rules_svc.set_no_rule(uid, lesson_id, no_rule)
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;