Files
FabledGrimoire/frontend/src/api/lessons.ts
T
bvandeusenandClaude Opus 5 edbc31f8ca
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
feat(lessons): the kind whose whole question is "is this trigger right" was the one kind that could not see its own counts (#4196)
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
2026-09-21 02:33:33 -04:00

151 lines
5.7 KiB
TypeScript

import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** A lesson: a transferable insight, retrievable by the SITUATION it applies
* to rather than by its topic.
*
* The fields mirror what the backend composes and reads back
* (`services/lessons.py::lesson_to_dict`), not the stored row. `title` and
* `body` are DERIVED — the service builds them from `what`, `when_to_apply`
* and `insight` — so an editor sends the three parts and never the document.
* That is the whole design: the trigger ends up in the title and again at the
* head of the body, which is what makes a lesson rank on when it applies. */
export interface Lesson {
id: number;
/** The composed document title, `{what} — {when_to_apply}`. Read-only. */
title: string;
/** The composed body. Read-only — edit `insight` instead. */
body: string;
/** The claim itself, as you would say it. */
what: string;
/** WHEN this applies — the situation, in the words it presents itself in.
* The entire retrieval story: a lesson without one saves, reads correctly
* and never surfaces, so both doors refuse an empty one. */
when_to_apply: string;
/** The body with the composed lines stripped — what an edit form binds to,
* so saving doesn't accumulate a copy of the trigger line per save. */
insight: string;
/** Ids of the records that taught this — issues, tasks or notes. */
learned_from: number[];
/** The same sources RESOLVED, sent by the detail route only. A bare "#4181"
* on a page tells a reader nothing about whether it is worth opening, and
* the provenance is the point of a lesson — one that loses its incidents
* loses its evidence. A source that has been deleted drops out rather than
* rendering a link to nothing. */
learned_from_records?: {
id: number;
title: string;
note_type: string;
is_task: boolean;
task_kind: string | null;
status: string | null;
}[];
tags: string[];
note_type: string;
/** Where it was LEARNED. Kept as a fact, but not a limit on where it can be
* found: a lesson is retrievable from every project (milestone 385 step 3). */
project_id: number | null;
permission?: string;
created_at: string | null;
updated_at: string | null;
systems?: { id: number; name: string }[];
usage?: RecordUsage;
/** Set when another user owns this record. */
shared?: boolean;
owner?: string | null;
}
/** A row in the browse listing — the trigger travels with it, because a list
* of lessons without their triggers is a list of claims with the half that
* says when each one matters left off. */
export interface LessonListRow {
id: number;
title: string;
tags: string[];
when_to_apply?: string;
snippet?: string;
/** Surfaced-vs-opened for this lesson. Always present on a listing from
* this door, zero-filled where nothing has been recorded — absent only
* when a row came from somewhere else. */
usage?: RecordUsage;
shared?: boolean;
owner?: string | null;
}
export interface LessonListResponse {
lessons: LessonListRow[];
total: number;
}
/** What the create/update forms send. `what` and `when_to_apply` are required
* on create; every field is optional on update, and the service re-composes
* the whole document from the merged set — so a partial save can never leave
* the title and body disagreeing about the trigger. */
export interface LessonPayload {
what?: string;
when_to_apply?: string;
insight?: string;
learned_from?: number[];
tags?: string[];
project_id?: number | null;
system_ids?: number[];
/** Deliberate override of the near-duplicate gate, once the writer has seen
* the warning. Two lessons under one trigger compete for one reserved slot,
* so a duplicate displaces rather than merely clutters. */
force?: boolean;
}
export function listLessons(params: {
q?: string;
tag?: string;
project_id?: number;
limit?: number;
offset?: number;
} = {}): Promise<LessonListResponse> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.tag) qs.set("tag", params.tag);
if (params.project_id) qs.set("project_id", String(params.project_id));
if (params.limit != null) qs.set("limit", String(params.limit));
if (params.offset != null) qs.set("offset", String(params.offset));
const suffix = qs.toString() ? `?${qs}` : "";
return apiGet<LessonListResponse>(`/api/lessons${suffix}`);
}
export function getLesson(id: number): Promise<Lesson> {
return apiGet<Lesson>(`/api/lessons/${id}`);
}
export function createLesson(payload: LessonPayload): Promise<Lesson> {
return apiPost<Lesson>("/api/lessons", payload);
}
export function updateLesson(
id: number,
payload: LessonPayload,
): Promise<Lesson> {
return apiPatch<Lesson>(`/api/lessons/${id}`, payload);
}
/** Trash, not erase — recoverable. `apiDelete` discards the body, which is the
* established shape here (snippets delete the same way): the batch id is in
* the response, but no caller has needed it and inventing a second delete
* helper to carry it would be the duplication, not the feature. */
export function deleteLesson(id: number): Promise<void> {
return apiDelete(`/api/lessons/${id}`);
}
/** 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. */
export function lessonsTaughtBy(
recordId: number,
): Promise<{ lessons: Lesson[]; taught_by: number }> {
return apiGet<{ lessons: Lesson[]; taught_by: number }>(
`/api/lessons/taught-by/${recordId}`,
);
}