diff --git a/frontend/src/api/lessons.ts b/frontend/src/api/lessons.ts index 4b66ee8..d3d7d02 100644 --- a/frontend/src/api/lessons.ts +++ b/frontend/src/api/lessons.ts @@ -1,6 +1,34 @@ import type { RecordUsage } from "@/types/usage"; -import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; +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. @@ -51,6 +79,13 @@ export interface Lesson { 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 }; /** Set when another user owns this record. */ shared?: boolean; owner?: string | null; @@ -128,6 +163,17 @@ export function updateLesson( return apiPatch(`/api/lessons/${id}`, payload); } +/** 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 diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 331bd34..16c3c25 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -1,6 +1,7 @@ import type { RecordUsage } from "@/types/usage"; import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; +import type { LinkState } from "@/api/lessons"; /** How a rule reaches a session (milestone 307). */ @@ -82,6 +83,10 @@ export interface Rule { /** Present only when the rule has them (the server omits empty keys). */ systems?: { id: number; name: string }[]; relations?: RuleRelation[]; + /** The lessons that point at this rule (milestone 440) — the concrete + * situations judged instances of it, plus any suggested and awaiting a + * judgment. Readable lessons only; omitted when there are none. */ + lessons?: { id: number; title: string; state: LinkState; note: string }[]; } /** diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue index 19ebc16..d33b7f9 100644 --- a/frontend/src/components/rules/RuleEditorSlideOver.vue +++ b/frontend/src/components/rules/RuleEditorSlideOver.vue @@ -27,6 +27,14 @@ const expiresWhen = ref(""); const relations = computed(() => store.currentRule?.relations ?? []); +// The lessons that point at this rule (milestone 440): confirmed instances +// first, then suggestions waiting on a judgment. A rejected lesson does not +// point at the rule, so it is not listed here — it stays readable on the +// lesson, where the judgment was made. +const lessons = computed(() => + (store.currentRule?.lessons ?? []).filter((l) => l.state !== "rejected"), +); + // The label a reader needs to judge an edge, not the stored token. const RELATION_LABEL: Record = { co_surfaces: { outgoing: "arrives with", incoming: "arrives with" }, @@ -279,6 +287,21 @@ watch(() => props.ruleId, load);

+
+

Lessons that are instances of it

+
    +
  • + {{ l.title }} + suggested + {{ l.note }} +
  • +
+

+ The situations that keep proving this rule. A confirmed lesson brings the rule along + when it surfaces; a suggested one waits for a judgment on the lesson's page. +

+
+