feat(lessons): the REST door a human can actually reach (#3734)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 48s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 23s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 48s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 23s
Step 7, part two. Milestone 385 built the lesson kind through the MCP tools, which is the agent's surface. The Vue app speaks REST, so a lesson was a record a person could not create, read, edit or retire — rule 27 failing at the door rather than in the view. `/api/lessons` now offers list, create, read, update and trash, plus `/api/lessons/taught-by/<id>` — the reverse of `learned_from`, which the task body calls the direction that gets forgotten and arguably the more useful one: a reader opening an old issue wants to know what was learned from it, and until now the relation was only navigable from the lesson's side. `lessons_taught_by` reads `data[taught_by]` through `path_exists`, the same jsonpath dialect the snippet location lookup uses, so both reverse lookups hit the GIN index (0070) the same way rather than scanning bodies. Share-aware via `readable_notes_clause`: it renders beside a record the caller can already see, so a lesson shared with them belongs there exactly as their own does. THE TRIGGER IS REFUSED WHEN EMPTY, at create and at update. This is the one place the door is not a thin wrapper, and it is deliberate: the service will store a triggerless lesson quite happily — it saves, reads correctly in every listing, and never surfaces. There is nothing to notice afterwards, because it looks exactly like a lesson that works. Better to refuse it than to hand back a record that looks finished. The refusal says why, so the next reader does not take it for a nag and delete it. `lesson_to_dict` moves into the service and the MCP tool's `_to_dict` becomes an alias for it. Both doors now return one shape — a payload spelled once per door answers the two of them differently the first time a field is added — and both compose through `services/lessons.py`, so a lesson written from the web ranks identically to one written by an agent. The document IS what ranks, so that parity is the whole reason the door is thin. The dedup gate matches the MCP path: two lessons under one trigger compete in a single ranked list for one reserved slot, so a duplicate here displaces rather than merely clutters. NOT DONE YET: this is the door, not the UI. #3734 stays in_progress until the Vue views, the router entries, the Knowledge browse badge and the both-ways sources panel exist — rule 27 is about the operator being able to touch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -88,6 +88,9 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
|
||||
LESSON_NOTE_TYPE = "lesson"
|
||||
|
||||
# The key in `notes.data`. Named for the field it mirrors on `rules`, because it
|
||||
@@ -415,6 +418,78 @@ async def get_lesson(user_id: int, lesson_id: int):
|
||||
return note
|
||||
|
||||
|
||||
def lesson_to_dict(note) -> dict:
|
||||
"""A lesson as either door returns it — the composed fields read back out,
|
||||
not the raw row, so a caller sees the same vocabulary it wrote with.
|
||||
|
||||
In the SERVICE rather than in each door, on the `snippet_to_dict`
|
||||
precedent: the REST route feeds the web UI and the MCP tools feed an
|
||||
agent, and a shape spelled once per door is a shape that answers the two
|
||||
of them differently the first time a field is added.
|
||||
"""
|
||||
return {
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
"body": note.body,
|
||||
# The composed vocabulary, not the storage: a caller that wrote
|
||||
# `when_to_apply` reads `when_to_apply` back.
|
||||
"what": (note.data or {}).get("what", "") if isinstance(note.data, dict) else "",
|
||||
"when_to_apply": lesson_trigger(note),
|
||||
"learned_from": lesson_sources(note),
|
||||
"insight": _strip_composed_lines(note.body),
|
||||
"tags": list(note.tags or []),
|
||||
"project_id": note.project_id,
|
||||
"note_type": note.note_type,
|
||||
"created_at": note.created_at.isoformat() if note.created_at else None,
|
||||
"updated_at": note.updated_at.isoformat() if note.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def lessons_taught_by(user_id: int, record_id: int, limit: int = 20):
|
||||
"""The lessons drawn FROM one record — the reverse of `learned_from`.
|
||||
|
||||
THE DIRECTION THAT GETS FORGOTTEN, and arguably the more useful one: 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. A
|
||||
record that taught something should say so on its own page.
|
||||
|
||||
Queried through `data[SOURCES_KEY]` rather than by scanning bodies: the
|
||||
mirror is JSONB with a GIN index (0070), which is the whole reason step 4
|
||||
put the list there. `path_exists` is the same dialect the snippet location
|
||||
lookup uses, so both reverse lookups read the index the same way.
|
||||
|
||||
Share-aware (rule 78) via `readable_notes_clause`: this renders beside a
|
||||
record the caller can already see, and a lesson someone shared with them
|
||||
belongs in that list exactly as their own does.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.services.access import readable_notes_clause
|
||||
|
||||
try:
|
||||
wanted = int(record_id)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if wanted <= 0:
|
||||
return []
|
||||
|
||||
# The id is an int we just validated, never caller text, so it cannot
|
||||
# break out of the expression — the same guarantee `location_jsonpath`
|
||||
# gets from JSON-quoting its values.
|
||||
jsonpath = f"$.{SOURCES_KEY}[*] ? (@ == {wanted})"
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Note)
|
||||
.where(Note.note_type == LESSON_NOTE_TYPE)
|
||||
.where(Note.deleted_at.is_(None))
|
||||
.where(Note.data.path_exists(jsonpath))
|
||||
.where(readable_notes_clause(user_id))
|
||||
.order_by(Note.updated_at.desc())
|
||||
.limit(max(1, min(limit, 100)))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_lesson(
|
||||
user_id: int,
|
||||
lesson_id: int,
|
||||
|
||||
Reference in New Issue
Block a user