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
+46 -2
View File
@@ -16,7 +16,7 @@ from scribe.models.rule_usage import RuleUsageEvent
from scribe.models.retrieval_tuning import RetrievalTuningEvent
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
from scribe.models.lesson_rule_link import LessonRuleLink
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.project import Project
from scribe.models.repo_binding import RepoBinding
@@ -85,8 +85,11 @@ logger = logging.getLogger(__name__)
# is an instance of, and the judgments that confirmed or rejected each pair. A
# confirmed link is a judgment nothing else records, and a rejected one is
# what stops the pair being proposed again — losing either undoes work.
# v19 (2026-10) added lesson_no_rule (#4631): the "no rule fits" answer and its
# reason. Without it a restored lesson that was judged to stand alone reads as
# never judged, and lands back on the unjudged list.
# Bump when the serialized schema changes.
BACKUP_VERSION = 18
BACKUP_VERSION = 19
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -126,6 +129,8 @@ _BACKED_UP = [
"retrieval_tuning_events",
# v18 (2026-10): lesson → rule links and their judgments (milestone 440).
"lesson_rule_links",
# v19 (2026-10): "no rule fits" answers (#4631).
"lesson_no_rule",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -221,6 +226,8 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"rule_relations": {"id", "created_at"},
# The pair is the row; everything else is the judgment and its evidence.
"lesson_rule_links": {"id"},
# Keyed by the lesson itself; nothing to exclude, every column travels.
"lesson_no_rule": set(),
"note_usage_events": {"id"},
# Same as the note twin: the surrogate key is re-issued on insert.
"rule_usage_events": {"id"},
@@ -309,6 +316,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"note_supersessions": {"id", "created_at"},
"rule_relations": {"id", "created_at"},
"lesson_rule_links": {"id"},
"lesson_no_rule": set(),
"note_usage_events": {"id"},
"rule_usage_events": {"id"},
"retrieval_tuning_events": {"id"},
@@ -717,6 +725,17 @@ def _lesson_rule_link_rows(rows) -> list[dict]:
]
def _lesson_no_rule_rows(rows) -> list[dict]:
"""The "no rule fits" answers, by SOURCE lesson id (#4631)."""
return [
{
"lesson_id": r.lesson_id, "why": r.why,
"judged_at": r.judged_at.isoformat() if r.judged_at else None,
}
for r in rows
]
def _rule_rows(rows) -> list[dict]:
return [
{
@@ -765,6 +784,7 @@ async def export_full_backup() -> dict:
)).all()
rule_relations = (await session.execute(select(RuleRelation))).scalars().all()
lesson_rule_links = (await session.execute(select(LessonRuleLink))).scalars().all()
lesson_no_rule = (await session.execute(select(LessonNoRule))).scalars().all()
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
supersessions = (
await session.execute(select(NoteSupersession))
@@ -824,6 +844,7 @@ async def export_full_backup() -> dict:
"rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations),
"lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links),
"lesson_no_rule": _lesson_no_rule_rows(lesson_no_rule),
"systems": _system_rows(
systems, {c.id: c.slug for c in canonical_systems}
),
@@ -992,6 +1013,9 @@ async def export_user_backup(user_id: int) -> dict:
LessonRuleLink.rule_id.in_(_rule_ids),
)
)).scalars().all() if (_rule_ids and note_ids) else []
lesson_no_rule = (await session.execute(
select(LessonNoRule).where(LessonNoRule.lesson_id.in_(note_ids))
)).scalars().all() if note_ids else []
return {
"version": BACKUP_VERSION,
@@ -1020,6 +1044,7 @@ async def export_user_backup(user_id: int) -> dict:
"rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations),
"lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links),
"lesson_no_rule": _lesson_no_rule_rows(lesson_no_rule),
"systems": _system_rows(
systems, {c.id: c.slug for c in canonical_systems}
),
@@ -1365,6 +1390,17 @@ def _build_rule_relation(row: dict, maps: _Maps) -> RuleRelation | None:
)
def _build_lesson_no_rule(row: dict, maps: _Maps) -> LessonNoRule | None:
"""Skipped when its lesson did not restore — the answer is about that
lesson and no other."""
lesson = maps.notes.get(row.get("lesson_id", 0))
if lesson is None or not (row.get("why") or "").strip():
return None
return LessonNoRule(
lesson_id=lesson, why=row["why"], judged_at=_dt(row.get("judged_at")),
)
def _build_lesson_rule_link(row: dict, maps: _Maps) -> LessonRuleLink | None:
"""Both ends must map: a link whose lesson or rule did not restore points
at whatever took that number in the destination."""
@@ -1782,6 +1818,7 @@ async def _restore_v2(data: dict) -> dict:
"code_shape_uses": 0, "canonical_systems": 0,
"rule_systems": 0, "rule_relations": 0, "rule_versions": 0,
"retrieval_tuning_events": 0, "lesson_rule_links": 0,
"lesson_no_rule": 0,
}
async with async_session() as session:
@@ -1985,6 +2022,13 @@ async def _restore_v2(data: dict) -> dict:
continue
session.add(link)
stats["lesson_rule_links"] += 1
# "No rule fits" answers (#4631); archives before v19 carry none.
for nr in data.get("lesson_no_rule", []):
answer = _build_lesson_no_rule(nr, maps)
if answer is None:
continue
session.add(answer)
stats["lesson_no_rule"] += 1
# A rule's edit history (milestone 323). Must come after the rules
# themselves — the rule map is only populated above — and both ids are