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
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:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user