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
+79 -10
View File
@@ -29,7 +29,7 @@ _to_dict = lessons_svc.lesson_to_dict
async def list_lessons(
q: str = "", tag: str = "", limit: int = 50, offset: int = 0,
project_id: int = 0,
project_id: int = 0, unjudged: bool = False,
) -> dict:
"""List lessons — the kind enumerated, rather than only what a query
resembles.
@@ -51,16 +51,32 @@ async def list_lessons(
project_id: Narrow to where a lesson was WRITTEN. 0 = every project.
A lesson is retrievable from anywhere regardless (step 3); this
filters the listing, not the reach.
unjudged: List only the lessons nobody has answered "which rule is
this an instance of?" for — no rule named, and no "no rule fits"
recorded. Work down this list with `update_lesson(rule_ids=…)` or
`update_lesson(no_rule="why")`. A listing rather than a search, so
it takes `tag` and `project_id` but not `q`.
Returns {"lessons": [{id, title, when_to_apply, tags, preview}], "total"}.
"""
uid = current_user_id()
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 or None,
limit=max(1, min(limit, 100)), offset=max(0, offset),
project_id=project_id or None,
)
if unjudged:
if q:
raise ValueError(
"unjudged lists every unjudged lesson rather than searching "
"them — call it with tag/project_id and without q."
)
items, total = await lesson_rules_svc.list_unjudged(
uid, tag=tag, project_id=project_id or None,
limit=max(1, min(limit, 100)), offset=max(0, 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 or None,
limit=max(1, min(limit, 100)), offset=max(0, offset),
project_id=project_id or None,
)
labelled = await access_svc.label_shared_items(uid, items)
# One aggregate for the page, like the snippet listing — surfaced-vs-opened
# per lesson (#4196). An agent listing lessons can see which of its own
@@ -91,11 +107,29 @@ async def create_lesson(
project_id: int = 0,
system_ids: list[int] | None = None,
rule_ids: list[int] | None = None,
no_rule: str = "",
force: bool = False,
) -> dict:
"""Record something you LEARNED, so a later session meets it at the moment
it applies — on this project or any other.
A LESSON POINTS AT THE RULE IT IS AN INSTANCE OF. While you write it, you
know the situation better than anyone will again, so that is when to say
which binding choice it falls under. Answer one of two ways, in this call
or right after it with `update_lesson`:
- `rule_ids=[…]` — the rule(s) or preference(s) it is an instance of. The
rule then becomes reachable through the situation the lesson describes,
which is closer to why it was written than its own wording often is.
- `no_rule="why"` — no rule governs this situation, in a line. That is a
real answer: a lesson that stands alone is what a rule nobody has
written yet is made from, and the reason lets it be re-judged later.
A lesson created with neither comes back with `rule_candidates` — the
rules it most resembles, each with its trigger — and `rule_judgment:
"unjudged"`. Read them against the lesson and answer; `list_lessons(
unjudged=true)` gathers any left open.
A LESSON OR A RULE? The difference is FORCE, not importance. A rule is
something that must be followed; a lesson is something worth knowing. If
ignoring it would be a mistake, it is a rule (create_rule) and needs the
@@ -151,11 +185,14 @@ async def create_lesson(
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.
(milestone 440). Naming a rule here confirms the link.
no_rule: The reason no rule governs this situation, in a line — the
other answer to "which rule?". Give one or the other, not both.
force: Create even if a near-duplicate exists.
Returns the created lesson. On a near-duplicate, returns the existing id
Returns the created lesson with its `rules` and `rule_judgment`, plus
`rule_candidates` when it is unjudged (absent when the rule search could
not run). On a near-duplicate, returns the existing id
instead of creating — two lessons about one failure class want to be one
lesson, so update that one rather than adding a second.
"""
@@ -172,6 +209,7 @@ async def create_lesson(
# 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)
lesson_rules_svc.require_one_answer(linked, no_rule)
title, body = lessons_svc.lesson_document(
what, when_to_apply, insight, sources,
)
@@ -192,12 +230,34 @@ async def create_lesson(
await systems_svc.set_record_systems(uid, note.id, system_ids)
if linked:
await lesson_rules_svc.set_lesson_rules(uid, note.id, linked)
elif no_rule.strip():
await lesson_rules_svc.set_no_rule(uid, note.id, no_rule)
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])
if not linked and not no_rule.strip():
await _offer_candidates(uid, data, what, when_to_apply, project_id)
return data
async def _offer_candidates(uid: int, data: dict, what: str, trigger: str, project_id: int) -> None:
"""Put the rules an unjudged lesson resembles in front of its writer.
`rule_judgment` is set here too, because the attach above is fail-open and
may have left it off; a lesson just created with no answer IS unjudged.
"""
data["rule_judgment"] = lesson_rules_svc.UNJUDGED
candidates = await lesson_rules_svc.rule_candidates(uid, what, trigger, project_id or None)
if candidates is not None:
data["rule_candidates"] = candidates
data["rule_hint"] = (
"Which rule is this lesson an instance of? If one of rule_candidates "
"governs its situation, update_lesson(lesson_id, rule_ids=[…]) links "
"it; if none does, update_lesson(lesson_id, no_rule=\"why\") records "
"that it stands alone."
)
async def get_lesson(lesson_id: int, project_id: int = 0) -> dict:
"""Fetch one lesson by id, with its trigger and sources read back out.
@@ -245,6 +305,7 @@ async def update_lesson(
tags: list[str] | None = None,
system_ids: list[int] | None = None,
rule_ids: list[int] | None = None,
no_rule: str = "",
) -> dict:
"""Update a lesson. Empty fields are left unchanged.
@@ -278,12 +339,18 @@ async def update_lesson(
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.
Naming a rule replaces any "no rule fits" answer.
no_rule: Record that no rule governs this lesson's situation, with the
reason in a line. Any rule still linked is rejected with that
reason. Empty leaves the answer unchanged; give this or a
non-empty `rule_ids`, not both.
"""
uid = current_user_id()
linked = (
await lesson_rules_svc.require_rules(uid, rule_ids)
if rule_ids is not None else None
)
lesson_rules_svc.require_one_answer(linked, no_rule)
note = await lessons_svc.update_lesson(
uid, lesson_id,
what=what or None,
@@ -302,6 +369,8 @@ async def update_lesson(
note = await lessons_svc.get_lesson(uid, lesson_id) or note
if linked is not None:
await lesson_rules_svc.set_lesson_rules(uid, lesson_id, linked)
if no_rule.strip():
await lesson_rules_svc.set_no_rule(uid, lesson_id, no_rule)
out = _to_dict(note)
await lesson_rules_svc.attach_lesson_rules(uid, [out])
return out