Files
FabledScribe/src/scribe/routes/lessons.py
T
bvandeusenandClaude Opus 5.5 6d3dca0af5
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 27s
feat(lessons): convergence is named at the write — no-rule lessons that keep landing in one situation suggest a rule (milestone 440 step 5, #4634)
When a lesson is answered "no rule fits" (create_lesson / update_lesson on
both doors), the response looks for other no-rule lessons it resembles and,
once there are CONVERGENCE_LESSONS (3) of them, carries `convergence`: the
members, their incidents and projects, and a hint to draft the missing rule
with create_rule (operator approval as always) and point each lesson at it —
or to leave them as lessons when no single choice is right every time.

- convergence_group is the pure bar: distinct LESSONS count, incidents never
  stand in for them (one broad lesson cannot trigger it), and a group whose
  sources all point at one incident is one event written up several times.
- convergence_for searches lessons by the new one's claim + trigger
  (trigger_title) at CONVERGENCE_THRESHOLD 0.65 — above the menu's "worth
  showing", below the duplicate gate's "same record" — then keeps the ones
  with a lesson_no_rule answer. Fail-open. No sweep, no timer (#4183).
- Defaults stated as defaults (rules 32, 115).
- Tests: the bar (pure), the search with stubs, the door, and the no-rule
  filter against Postgres; conftest stubs convergence_for for unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:31:15 -04:00

345 lines
15 KiB
Python

"""REST routes for lessons — a transferable insight, retrievable by situation.
A lesson is a note with note_type='lesson' (see services/lessons.py). These
routes feed the web UI; the MCP tools (mcp/tools/lessons.py) are the
agent-facing surface. Both go through services/lessons.py, so the compose/parse
contract and the `data` mirror live in one place — the same division snippets
use, and for the same reason: two doors that each compose a lesson would
compose it two ways, and the document IS what ranks.
ACL (rule #78): reads and writes of a single lesson resolve through the
share-aware `get_lesson` / `can_write_note`, and writes are performed as the
OWNER so a shared editor isn't rejected by the owner-scoped service —
mirroring routes/snippets.py and routes/notes.py.
WHY THE TRIGGER IS A NAMED FIELD HERE TOO. The web editor could have posted a
body and let the service parse it. It doesn't, because the evidence behind
this kind (step 1) is that a trigger gets filled when a door ASKS for it by
name — the snippet corpus is at 100% on its trigger with no guard anywhere,
because a service composes the title from a parameter. A form that offered one
markdown box would be the option milestone 385 rejected, wearing a different
hat.
"""
import logging
from quart import Blueprint, jsonify, request
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
from scribe.services.access import (
can_write_note,
describe_provenance,
label_shared_items,
)
from scribe.services.note_usage import attach_usage, record_pulled
logger = logging.getLogger(__name__)
lessons_bp = Blueprint("lessons", __name__, url_prefix="/api/lessons")
@lessons_bp.route("", methods=["GET"])
@login_required
async def list_lessons_route():
"""The kind enumerated, rather than only what a query resembles.
Semantic search is how a lesson REACHES a session; this is how a person
sees what exists at all. Each row carries its trigger, because a list of
lessons without them is a list of claims with the half that says when each
one matters left off.
"""
uid = get_current_user_id()
q = request.args.get("q") or None
tag = request.args.get("tag", "")
try:
project_id = int(request.args.get("project_id", 0) or 0) or None
except (TypeError, ValueError):
project_id = None
limit, offset = parse_pagination(default_limit=24, max_limit=100)
# 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
# "never surfaced" rather than treating a missing field as a state.
#
# Lessons were the one kind collecting this and showing it nowhere (#4196).
# The counts matter more here than on a snippet: the promotion question a
# lesson eventually raises — does this bind? — is answered by repeatedly
# surfaced AND repeatedly opened, and the far commoner reading of the same
# row is that the trigger fires on the wrong situation, which `update_lesson`
# exists to fix.
await attach_usage(items)
return jsonify({"lessons": items, "total": total})
@lessons_bp.route("/taught-by/<int:record_id>", methods=["GET"])
@login_required
async def lessons_taught_by_route(record_id: int):
"""The lessons drawn FROM one record — the reverse of `learned_from`.
Registered ABOVE the `/<int:lesson_id>` routes on purpose: Quart matches
in registration order, and `taught-by` would otherwise never be reached
if the converter ever widened. The same ordering snippets' `/duplicates`
route documents.
This is the direction that gets forgotten. A reader opening an old issue
wants to know what was learned from it, and without this the relation is
only navigable from the lesson's side.
"""
uid = get_current_user_id()
notes = await lessons_svc.lessons_taught_by(uid, record_id)
return jsonify({
"lessons": [lessons_svc.lesson_to_dict(n) for n in notes],
"taught_by": record_id,
})
@lessons_bp.route("", methods=["POST"])
@login_required
async def create_lesson_route():
uid = get_current_user_id()
data = await request.get_json() or {}
what = (data.get("what") or "").strip()
when_to_apply = (data.get("when_to_apply") or "").strip()
if not what:
return jsonify({"error": "what is required"}), 400
# The trigger is not optional at this door even though the service will
# store a lesson without one. A lesson with no trigger saves, reads
# correctly in every listing, and never surfaces — there is nothing to
# notice afterwards, which is exactly why the form has to refuse it here
# rather than leave the writer a record that looks finished.
if not when_to_apply:
return jsonify({
"error": "when_to_apply is required",
"detail": (
"A lesson is found by the SITUATION it applies to. Without a "
"trigger it still saves and still reads correctly, and it "
"never reaches anyone — so it is refused here rather than "
"stored as a record that looks finished."
),
}), 400
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.
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
# 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,
# so the duplicate does not merely clutter — it displaces.
if not data.get("force"):
title, body = lessons_svc.lesson_document(
what, when_to_apply, data.get("insight", ""), learned_from,
)
dup = await dedup_svc.find_duplicate_note(
uid, title, body,
project_id=project_id,
is_task=False,
note_type=lessons_svc.LESSON_NOTE_TYPE,
data=lessons_svc.compose_data(what, when_to_apply),
)
if dup is not None:
return jsonify(dedup_svc.duplicate_response(dup, "lesson")), 409
note = await lessons_svc.create_lesson(
uid,
what=what,
when_to_apply=when_to_apply,
insight=data.get("insight", ""),
learned_from=learned_from,
tags=data.get("tags"),
project_id=project_id,
)
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)
elif no_rule:
await lesson_rules_svc.set_no_rule(uid, note.id, no_rule)
out = lessons_svc.lesson_to_dict(note)
if no_rule:
group = await lesson_rules_svc.convergence_for(uid, note.id)
if group:
out["convergence"] = group
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
@lessons_bp.route("/<int:lesson_id>", methods=["GET"])
@login_required
async def get_lesson_route(lesson_id: int):
uid = get_current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
if note is None:
return not_found("Lesson")
out = lessons_svc.lesson_to_dict(note)
# As the OWNER: a shared reader isn't scoped to the owner's project, so
# their own id would come back empty (the write-as-owner pattern this
# module already uses, read side).
out["systems"] = [
s.to_dict()
for s in await systems_svc.list_record_systems(note.user_id, lesson_id)
]
# Resolved, not bare ids: "#4181" on a page tells a reader nothing about
# whether it is worth opening, and the provenance is the point of a lesson.
out["learned_from_records"] = await lessons_svc.source_records(
uid, out["learned_from"]
)
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).
record_pulled(user_id=uid, note_id=lesson_id, source="rest_lesson")
return jsonify(out)
@lessons_bp.route("/<int:lesson_id>", methods=["PATCH"])
@login_required
async def update_lesson_route(lesson_id: int):
uid = get_current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
if note is None:
return not_found("Lesson")
if not await can_write_note(uid, lesson_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note.user_id
data = await request.get_json() or {}
# Partial update: only keys present in the payload change, and the service
# re-composes title, body and mirror from the merged set — so a form that
# sends one field cannot leave the halves of the document disagreeing.
kwargs = {
k: data[k]
for k in ("what", "when_to_apply", "insight", "learned_from", "tags")
if k in data
}
# An empty trigger would save and silently stop the lesson surfacing, so
# clearing it is refused for the same reason creating without one is.
if "when_to_apply" in kwargs and not (kwargs["when_to_apply"] or "").strip():
return jsonify({
"error": "when_to_apply cannot be cleared",
"detail": (
"A lesson with no trigger never surfaces, and nothing about "
"the stored record would show it. Rewrite the trigger rather "
"than emptying it."
),
}), 400
linked = None
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"])
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:
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 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;
# handing it the owner makes that check trivially pass and filters by
# the owner's visibility instead. The caller's write permission is
# already established above, so this neither loosens nor tightens who
# may edit — it decides WHOSE reach the tagging uses (#47).
await systems_svc.set_record_systems(uid, lesson_id, data["system_ids"])
out = lessons_svc.lesson_to_dict(updated)
if no_rule:
# The same nudge the MCP door gives (#4634): this answer may complete
# a group of no-rule lessons in one situation.
group = await lesson_rules_svc.convergence_for(uid, lesson_id)
if group:
out["convergence"] = group
out["systems"] = [
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):
"""Trash, not erase — recoverable from the trash like every other kind."""
uid = get_current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
if note is None:
return not_found("Lesson")
if not await can_write_note(uid, lesson_id):
return jsonify({"error": "Permission denied"}), 403
batch_id = await trash_svc.delete(note.user_id, "note", lesson_id)
if batch_id is None:
return not_found("Lesson")
return jsonify({"deleted": lesson_id, "deleted_batch_id": batch_id})