feat(lessons): both records show the link in the web UI (milestone 440 step 7, #4635)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 41s

The lesson page gains an "Instance of" panel that holds one of three answers.
Unjudged is the fall-through, so it is stated rather than left blank:
- the rule(s) it was judged an instance of;
- "No rule — <why>";
- "Not yet judged".

Suggested links show what they rest on (distinct situations, and projects
when more than one), with Confirm / Not an instance for a reader who can
write. Rejected links stay listed with their reason.

The rule slide-over lists the lessons that are instances of it, plus the
suggestions waiting on a judgment. Each entry links through to the other
record, and kind and state wear the existing .rule-chip.

The write check moves to utils/permission.ts. The copy on the snippet page
looked for "edit", which the server never sends, so shared editors saw a
read-only page (#4640 "The snippet page hid its edit controls from shared
editors"). Guards pin the client's unions and write levels to the
service's, the model's and access.py's own values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:46:27 -04:00
co-authored by Claude Opus 5.5
parent 6d3dca0af5
commit 75cefe60e4
7 changed files with 368 additions and 6 deletions
+47 -1
View File
@@ -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<Lesson>(`/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<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
+5
View File
@@ -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 }[];
}
/**