"""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/", 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 `/` 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("/", 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("/", 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("/", 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})