import type { RecordUsage } from "@/types/usage"; import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client"; import type { RuleKind } from "@/api/rulebooks"; /** Where a lesson stands on "which rule is this an instance of?" * (`services/lesson_rules.py`: LINKED / NO_RULE / UNJUDGED). Three answers, * not two: "looked at and found to stand alone" and "nobody has looked" are * different facts, and the second is the one worth acting on. A rejected link * alone leaves a lesson unjudged — "not that rule" does not say whether * another one fits. */ export type RuleJudgment = "linked" | "no_rule" | "unjudged"; /** A link's state (`models/lesson_rule_link.py` LINK_STATES). */ export type LinkState = "suggested" | "confirmed" | "rejected"; /** One link from a lesson to a rule, in the reader's view: only rules the * reader owns are listed (`rules_for_lessons`). */ export interface LessonRuleLink { id: number; title: string; kind: RuleKind; /** `suggested` carries nothing in retrieval until it is judged; only * `confirmed` changes what surfaces; `rejected` is kept so the pair is * never proposed again. */ state: LinkState; /** Why it was confirmed or rejected. */ note: string; /** What a suggestion rests on — sent on suggested links only. */ evidence?: { situations: number; projects: number; co_surfaced: number }; } /** 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; /** The rules this lesson points at, confirmed first, then suggested, then * rejected. Absent (with `rule_judgment`) when the links could not be read * — which is "not attached", never "no rule". */ rules?: LessonRuleLink[]; rule_judgment?: RuleJudgment; /** The "no rule fits" answer, present when that is the judgment. */ no_rule?: { why: string; judged_at: string | null }; /** Sent by a create or update that recorded "no rule fits" when that answer * completes a group: lessons with no rule that keep landing in one * situation, which may want a rule written for it (#4634). */ convergence?: { lessons: { id: number; title: string }[] }; /** 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[]; /** The rules this lesson is an instance of. On update, a list is the full * new set: a confirmed rule left out is judged NOT an instance and becomes * rejected. Omit it to leave the links alone. */ rule_ids?: number[]; /** "No rule fits", with the reason. Exclusive with a non-empty `rule_ids`: * the server refuses both in one request. */ no_rule?: string; /** 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 { 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(`/api/lessons${suffix}`); } export function getLesson(id: number): Promise { return apiGet(`/api/lessons/${id}`); } export function createLesson(payload: LessonPayload): Promise { return apiPost("/api/lessons", payload); } export function updateLesson( id: number, payload: LessonPayload, ): Promise { return apiPatch(`/api/lessons/${id}`, payload); } /** A rule the lesson being written resembles (`rule_candidates`). */ export interface RuleCandidate { id: number; title: string; kind: RuleKind; when_to_apply: string; score: number; } /** The rules a lesson resembles, asked BEFORE it is saved so the writer can * name one in the same save. `null` means the search could not run, which is * different from an empty list ("nothing resembles it"). */ export function ruleCandidates(params: { what: string; when_to_apply: string; project_id?: number | null; }): Promise<{ candidates: RuleCandidate[] | null }> { const qs = new URLSearchParams({ what: params.what, when_to_apply: params.when_to_apply, }); if (params.project_id) qs.set("project_id", String(params.project_id)); return apiGet<{ candidates: RuleCandidate[] | null }>( `/api/lessons/rule-candidates?${qs}`, ); } /** Confirm or reject one lesson→rule link. Confirming also clears a "no rule * fits" answer: the two cannot both be the current answer. */ export function judgeLessonLink( lessonId: number, ruleId: number, verdict: "confirm" | "reject", note = "", ): Promise { return apiPut(`/api/lessons/${lessonId}/rules/${ruleId}`, { verdict, note }); } /** 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 { 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}`, ); }