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