CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 37s
The lesson editor now asks the question the create tool asks, while the writer still has the situation in mind. It offers three answers: - an instance of a rule, ticked from the rules the lesson resembles; - "No rule fits", with a required reason; - leave it open. The rules are fetched before the save from a new GET /api/lessons/rule-candidates. It runs the same rule_candidates search the create door replies with, and passes "search unavailable" through as null, apart from "nothing resembles it". An edit sends an answer only when it changed. Re-sending the same rules would re-stamp their judgments. Worse, a shared editor who cannot see the owner's rule would reject it by sending a list without it. Leaving a linked lesson open sends rule_ids=[], which is what clears the links. "Leave it open" is not offered once "no rule fits" is on file: the server has no way to take that answer back except by naming a rule. When "no rule fits" completes a convergence group, the editor says so in a toast, since the page it goes to does not recompute it. Guards check that the payload uses the names both routes read, that the three answers are offered, that the candidates route sits above the id route and keeps null apart from [], and that the editor reuses .rule-chip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
9.2 KiB
TypeScript
235 lines
9.2 KiB
TypeScript
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<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);
|
|
}
|
|
|
|
/** 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<unknown> {
|
|
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<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}`,
|
|
);
|
|
}
|