feat(lessons): a lesson is readable, writable and browsable by a human (#3734)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Failing after 31s
CI & Build / integration (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Skipped

Step 7's actual UI. Before this the frontend had zero lesson code — the kind
existed for agents only, which is rule 27 failing.

THE EDITOR ASKS FOR THE TRIGGER BY NAME, and leads with it. Three fields —
the trigger, the claim, the detail — never one markdown box. That is the
design step 1 settled, and the evidence is blunt: the snippet corpus carries
a trigger on every record with no guard anywhere, because a service composes
the title from a named parameter. What is at 100% is a named structured
field, not a writer remembering a convention. The trigger gets the most room,
its own explanation, and a save button that refuses without it and says why.

The form shows the composed title live, so the writer is agreeing to a
document they can read rather than one assembled out of sight. A 409 from the
duplicate gate is rendered as the record that already covers the moment, with
a link to improve it and an explicit override — not as a failure.

THE BROWSE VOCABULARY GAINS THE KIND, which #3161 warned this step not to get
wrong: a facet chip, a badge label, and routing to `/lessons/:id` rather than
the note editor, which cannot edit a trigger. The badge is neutral alongside
snippet and process — a hue would make the softest record in the corpus look
like the loudest, next to a rule that actually binds.

BOTH DIRECTIONS OF THE PROVENANCE. The detail page resolves `learned_from` to
titles rather than bare ids, because "#4181" tells a reader nothing about
whether it is worth opening. And `LessonsTaughtPanel` answers the reverse on
the record's own page — the direction the task body calls the one that gets
forgotten. It has no author to type it, which is exactly why it tends never
to get built. A component, not markup in the task editor, so the same panel
mounts on any record a lesson can cite instead of being written a second time
(#3207). Silent when empty: most records taught no lesson, and a panel that
says "None yet" everywhere is one people learn to skip.

GLOBAL-BY-DEFAULT IS MADE LEGIBLE. A lesson meeting you on a project it was
not written on reads as a bug unless the page says otherwise, so the origin
line says it as a property of the kind rather than as an apology.

Design system tokens throughout; no new raw hex. `--fs-error` rather than
`--fs-danger` — 31 uses against 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-19 14:11:39 -04:00
co-authored by Claude Opus 5
parent d36d68a20f
commit 95dc25eaab
9 changed files with 1011 additions and 4 deletions
+146
View File
@@ -0,0 +1,146 @@
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;
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);
}
export function deleteLesson(
id: number,
): Promise<{ deleted: number; deleted_batch_id: string }> {
return apiDelete<{ deleted: number; deleted_batch_id: string }>(
`/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}`,
);
}