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