CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Failing after 31s
CI & Build / integration (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Skipped
Step 7's actual UI. Before this the frontend had zero lesson code — the kind existed for agents only, which is rule 27 failing. THE EDITOR ASKS FOR THE TRIGGER BY NAME, and leads with it. Three fields — the trigger, the claim, the detail — never one markdown box. That is the design step 1 settled, and the evidence is blunt: the snippet corpus carries a trigger on every record with no guard anywhere, because a service composes the title from a named parameter. What is at 100% is a named structured field, not a writer remembering a convention. The trigger gets the most room, its own explanation, and a save button that refuses without it and says why. The form shows the composed title live, so the writer is agreeing to a document they can read rather than one assembled out of sight. A 409 from the duplicate gate is rendered as the record that already covers the moment, with a link to improve it and an explicit override — not as a failure. THE BROWSE VOCABULARY GAINS THE KIND, which #3161 warned this step not to get wrong: a facet chip, a badge label, and routing to `/lessons/:id` rather than the note editor, which cannot edit a trigger. The badge is neutral alongside snippet and process — a hue would make the softest record in the corpus look like the loudest, next to a rule that actually binds. BOTH DIRECTIONS OF THE PROVENANCE. The detail page resolves `learned_from` to titles rather than bare ids, because "#4181" tells a reader nothing about whether it is worth opening. And `LessonsTaughtPanel` answers the reverse on the record's own page — the direction the task body calls the one that gets forgotten. It has no author to type it, which is exactly why it tends never to get built. A component, not markup in the task editor, so the same panel mounts on any record a lesson can cite instead of being written a second time (#3207). Silent when empty: most records taught no lesson, and a panel that says "None yet" everywhere is one people learn to skip. GLOBAL-BY-DEFAULT IS MADE LEGIBLE. A lesson meeting you on a project it was not written on reads as a bug unless the page says otherwise, so the origin line says it as a property of the kind rather than as an apology. Design system tokens throughout; no new raw hex. `--fs-error` rather than `--fs-danger` — 31 uses against 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
257 lines
10 KiB
Python
257 lines
10 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 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 empty_usage, record_pulled, usage_for_notes
|
|
|
|
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)
|
|
|
|
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,
|
|
)
|
|
return jsonify({
|
|
"lessons": await label_shared_items(uid, 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 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,
|
|
)
|
|
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"])
|
|
out = lessons_svc.lesson_to_dict(note)
|
|
out["systems"] = [
|
|
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
|
]
|
|
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))
|
|
out["usage"] = (await usage_for_notes([lesson_id])).get(
|
|
lesson_id, empty_usage()
|
|
)
|
|
# 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
|
|
|
|
updated = await lessons_svc.update_lesson(owner_uid, lesson_id, **kwargs)
|
|
if updated is None:
|
|
return not_found("Lesson")
|
|
if data.get("system_ids") is not None:
|
|
await systems_svc.set_record_systems(
|
|
owner_uid, lesson_id, data["system_ids"]
|
|
)
|
|
out = lessons_svc.lesson_to_dict(updated)
|
|
out["systems"] = [
|
|
s.to_dict()
|
|
for s in await systems_svc.list_record_systems(owner_uid, lesson_id)
|
|
]
|
|
return jsonify(out)
|
|
|
|
|
|
@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})
|