feat(lessons): the kind whose whole question is "is this trigger right" was the one kind that could not see its own counts (#4196)
CI & Build / Python lint (push) Successful in 8s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 36s

The lesson slot has recorded surfaced-vs-opened since it shipped. Nothing
showed it. `get_lesson`'s REST door attached `usage` to the payload and no
view rendered it; the listing did not attach it at all, and neither MCP door
did.

#4196 asks when a lesson that keeps getting followed should become a rule, and
names the trap in the same breath: raw frequency cannot separate "this should
bind" from "this trigger is too broad", and the second is the commoner reading
by a wide margin. Surfaced-AND-opened can separate them. Neither question is
answerable by a reader who cannot see the numbers, which is why this is the
first step and not the threshold.

NO THRESHOLD IS PROPOSED HERE, deliberately. The corpus today is 10 lessons
with 7 recorded surfacings and 3 opens, over about fifteen hours of usage
data. A promotion rule fitted to that would be fitting noise — #3311's failure,
and the warning lesson #4228 was written to carry. `UsageBadge` already
declines to render a verdict under three surfacings for the same reason. So
#4196 stays open: its subject, the promotion path, is still unbuilt. What
lands is the evidence it needs.

  - REST `GET /api/lessons` and MCP `list_lessons` attach `usage` to every
    row, from one aggregate per page rather than a per-row read, which would
    be N+1 by construction. Every row carries the key zero-filled, so "never
    surfaced" is a state a reader can see rather than a missing field they
    have to interpret.
  - MCP `get_lesson` attaches it too, and reads it BEFORE recording its own
    pull. That door records a pull on every open — it has to, or the kind sits
    permanently at zero — which makes the order load-bearing in a way it is
    not for a kind that only counts. The REST detail door already ordered it
    this way; the two now agree about what the number means.
  - `LessonDetailView` renders `UsageBadge` (snippet #3460) rather than
    re-spelling the chip, with the advice keyed to this kind: a lesson that is
    repeatedly offered and never opened is usually keyed to a situation nobody
    is in, so it points at re-keying `when_to_apply`, not at deleting the
    claim.

Guards, in the two styles this pair of doors already uses: the MCP side driven
behaviourally through mocks, including the call ORDER for `get_lesson`; the
REST side on structure like its siblings in test_lesson_rest_door.py, because
the route is decorated and returns a Quart response. Rule 167's falsifier is
included.

KNOWN GAP, not fixed here: `KnowledgeView` is the only lesson LIST in the UI
and it reads `/knowledge`, not `/lessons` — so the REST listing change reaches
`frontend/src/api/lessons.ts::listLessons`, which currently has no consumer.
The agent-facing listing does reach a reader today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-21 02:33:33 -04:00
co-authored by Claude Opus 5
parent 5d06b74599
commit edbc31f8ca
5 changed files with 242 additions and 6 deletions
+13 -1
View File
@@ -17,7 +17,7 @@ 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.mcp.tools import systems as systems_tools
from scribe.services.note_usage import record_pulled
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
# The payload shape lives in the service (`lesson_to_dict`), shared with the
@@ -61,6 +61,11 @@ async def list_lessons(
project_id=project_id or None,
)
labelled = await access_svc.label_shared_items(uid, items)
# One aggregate for the page, like the snippet listing — surfaced-vs-opened
# per lesson (#4196). An agent listing lessons can see which of its own
# triggers are firing and which are not, which is the reading that leads to
# `update_lesson` rather than to a second lesson about the same failure.
usage = await usage_for_notes([int(it["id"]) for it in labelled])
rows = [
{
"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
@@ -68,6 +73,7 @@ async def list_lessons(
# Projected by `_note_to_item` straight off the `data` mirror —
# absent when the row carries none, rather than an empty string.
"when_to_apply": it.get("when_to_apply", ""),
"usage": usage.get(int(it["id"]), empty_usage()),
**({"shared": True, "owner": it.get("owner")} if it.get("shared") else {}),
}
for it in labelled
@@ -203,6 +209,12 @@ async def get_lesson(lesson_id: int, project_id: int = 0) -> dict:
# retrieval as any other note, so a getter that records nothing would leave
# the kind permanently at zero pulls — reading as dead weight beside kinds
# that merely had a counter (#2476, the repeat of #2245).
# Read BEFORE the pull is recorded, so the number an agent is shown is the
# one that was true when it asked — otherwise every first read of a lesson
# reports a pull that is its own.
out["usage"] = (await usage_for_notes([int(note.id)])).get(
int(note.id), empty_usage()
)
record_pulled(
user_id=uid, note_id=int(note.id),
source="mcp_get_lesson", project_id=project_id,
+15 -4
View File
@@ -72,10 +72,21 @@ async def list_lessons_route():
offset=offset,
project_id=project_id,
)
return jsonify({
"lessons": await label_shared_items(uid, items),
"total": total,
})
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.
usage = await usage_for_notes([int(it["id"]) for it in items])
for it in items:
it["usage"] = usage.get(int(it["id"]), empty_usage())
return jsonify({"lessons": items, "total": total})
@lessons_bp.route("/taught-by/<int:record_id>", methods=["GET"])