CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 37s
The lesson editor now asks the question the create tool asks, while the writer still has the situation in mind. It offers three answers: - an instance of a rule, ticked from the rules the lesson resembles; - "No rule fits", with a required reason; - leave it open. The rules are fetched before the save from a new GET /api/lessons/rule-candidates. It runs the same rule_candidates search the create door replies with, and passes "search unavailable" through as null, apart from "nothing resembles it". An edit sends an answer only when it changed. Re-sending the same rules would re-stamp their judgments. Worse, a shared editor who cannot see the owner's rule would reject it by sending a list without it. Leaving a linked lesson open sends rule_ids=[], which is what clears the links. "Leave it open" is not offered once "no rule fits" is on file: the server has no way to take that answer back except by naming a rule. When "no rule fits" completes a convergence group, the editor says so in a toast, since the page it goes to does not recompute it. Guards check that the payload uses the names both routes read, that the three answers are offered, that the candidates route sits above the id route and keeps null apart from [], and that the editor reuses .rule-chip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
16 KiB
Python
373 lines
16 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("/rule-candidates", methods=["GET"])
|
|
@login_required
|
|
async def lesson_rule_candidates_route():
|
|
"""The rules a lesson being written resembles, BEFORE it is saved
|
|
(milestone 440, #4658).
|
|
|
|
The create door offers the same list in its reply when neither answer was
|
|
given. A form needs it earlier, so the writer can name the rule in the
|
|
same save while the situation is still in mind. Query: `what`,
|
|
`when_to_apply`, optional `project_id`. Above the `/<int:lesson_id>`
|
|
routes for the reason `taught-by` is.
|
|
|
|
`candidates` is null when the search could not run. That means
|
|
"unavailable", which is different from "nothing resembles it" (an empty
|
|
list).
|
|
"""
|
|
uid = get_current_user_id()
|
|
what = (request.args.get("what") or "").strip()
|
|
when_to_apply = (request.args.get("when_to_apply") or "").strip()
|
|
if not what and not when_to_apply:
|
|
return jsonify({"error": "what or when_to_apply is required"}), 400
|
|
project_id = request.args.get("project_id", type=int) or None
|
|
candidates = await lesson_rules_svc.rule_candidates(
|
|
uid, what, when_to_apply, project_id,
|
|
)
|
|
return jsonify({"candidates": candidates})
|
|
|
|
|
|
@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})
|