From cf4206469b38d668bd15c514db707bd148815b51 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 1 Oct 2026 16:36:43 -0400 Subject: [PATCH] feat(lessons): the web editor records which rule a lesson is an instance of (milestone 440, #4658) 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 --- frontend/src/api/lessons.ts | 38 ++++ frontend/src/views/LessonEditorView.vue | 231 +++++++++++++++++++++++- src/scribe/routes/lessons.py | 28 +++ tests/test_lesson_rule_link_ui.py | 60 +++++- 4 files changed, 354 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/lessons.ts b/frontend/src/api/lessons.ts index d3d7d02..281ed8e 100644 --- a/frontend/src/api/lessons.ts +++ b/frontend/src/api/lessons.ts @@ -86,6 +86,10 @@ export interface Lesson { 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; @@ -125,6 +129,13 @@ export interface LessonPayload { 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. */ @@ -163,6 +174,33 @@ export function updateLesson( 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( diff --git a/frontend/src/views/LessonEditorView.vue b/frontend/src/views/LessonEditorView.vue index 3cbaf3b..475b538 100644 --- a/frontend/src/views/LessonEditorView.vue +++ b/frontend/src/views/LessonEditorView.vue @@ -16,6 +16,13 @@ * lesson with no trigger saves, reads correctly in every listing, and never * surfaces — and there is nothing to notice afterwards, because it looks * exactly like a lesson that works. The form is where that gets caught. + * + * WHICH RULE IS THIS AN INSTANCE OF (milestone 440) is asked here, while the + * writer still has the situation in mind, the same way the create tool asks + * it. There are three answers: the rule(s), "no rule fits" with a reason, or + * leave it open. None is forced, because a lesson left open is still a + * lesson. But the form offers the rules it resembles before the save, so + * naming one costs a click. */ import { computed, onMounted, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; @@ -24,9 +31,13 @@ import { apiErrorMessage } from "@/api/client"; import { createLesson, getLesson, + ruleCandidates, updateLesson, type Lesson, + type LessonPayload, + type RuleCandidate, } from "@/api/lessons"; +import type { RuleKind } from "@/api/rulebooks"; import ProjectSelector from "@/components/ProjectSelector.vue"; import TagInput from "@/components/TagInput.vue"; import { useNotesStore } from "@/stores/notes"; @@ -67,8 +78,106 @@ const previewTitle = computed(() => { return subject || trigger; }); +// ── which rule is this an instance of ────────────────────────────────────── + +type Answer = "rules" | "no_rule" | "open"; +const answer = ref("open"); +const selectedRuleIds = ref([]); +const noRuleWhy = ref(""); +/** The answer the lesson held when loaded. An edit sends an answer only when + * it changed: re-sending the same rules would re-stamp their judgments, and + * a shared editor who cannot see the owner's rule would reject it by + * re-sending a list without it. */ +const original = ref<{ answer: Answer; ruleIds: number[]; why: string }>({ + answer: "open", ruleIds: [], why: "", +}); +/** The rules already linked, kept in the list so they can be unticked even + * when the search no longer ranks them. */ +const linkedRules = ref<{ id: number; title: string; kind: RuleKind }[]>([]); +/** null until a search has answered. */ +const candidates = ref(null); +const searchUnavailable = ref(false); +const searching = ref(false); + +const ruleOptions = computed(() => { + const seen = new Set(); + const out: { id: number; title: string; kind: RuleKind; when_to_apply?: string }[] = []; + for (const r of [...linkedRules.value, ...(candidates.value ?? [])]) { + if (seen.has(r.id)) continue; + seen.add(r.id); + out.push(r); + } + return out; +}); + +/** The server has no way to take back "no rule fits" except by naming a + * rule, so once that is the answer, "leave it open" is not offered. */ +const canLeaveOpen = computed(() => original.value.answer !== "no_rule"); + +function sameIds(a: number[], b: number[]): boolean { + if (a.length !== b.length) return false; + const want = new Set(b); + return a.every((id) => want.has(id)); +} + +const answerChanged = computed(() => { + const o = original.value; + if (answer.value !== o.answer) return true; + if (answer.value === "rules") return !sameIds(selectedRuleIds.value, o.ruleIds); + if (answer.value === "no_rule") return noRuleWhy.value.trim() !== o.why; + return false; +}); + +/** Why the answer as it stands cannot be saved, or null when it can. */ +const answerProblem = computed(() => { + if (!answerChanged.value) return null; + if (answer.value === "rules" && !selectedRuleIds.value.length) { + return "Tick the rule this is an instance of, or choose another answer."; + } + if (answer.value === "no_rule" && !noRuleWhy.value.trim()) { + return "Say why no rule fits. A bare “none” cannot be judged again when a rule is later written for this situation."; + } + return null; +}); + +/** The answer's half of the payload. Empty when nothing changed. */ +function answerPayload(): Pick { + if (!answerChanged.value) return {}; + if (answer.value === "rules") return { rule_ids: selectedRuleIds.value }; + if (answer.value === "no_rule") return { no_rule: noRuleWhy.value.trim() }; + // Left open after being linked: an empty set rejects the linked rules. + return original.value.answer === "rules" ? { rule_ids: [] } : {}; +} + +async function findRules() { + if (!what.value.trim() && !whenToApply.value.trim()) return; + searching.value = true; + try { + const res = await ruleCandidates({ + what: what.value.trim(), + when_to_apply: whenToApply.value.trim(), + project_id: projectId.value, + }); + candidates.value = res.candidates ?? []; + searchUnavailable.value = res.candidates === null; + } catch (e) { + toast.show(apiErrorMessage(e, "Failed to look for rules"), "error"); + } finally { + searching.value = false; + } +} + +// Search the first time the writer says it is an instance of a rule, including +// when an edit opens on a linked lesson. +watch(answer, (a) => { + if (a === "rules" && candidates.value === null && !searching.value) findRules(); +}); + const canSave = computed( - () => what.value.trim().length > 0 && whenToApply.value.trim().length > 0, + () => + what.value.trim().length > 0 && + whenToApply.value.trim().length > 0 && + answerProblem.value === null, ); async function load() { @@ -85,6 +194,18 @@ async function load() { tags.value = lesson.tags ?? []; projectId.value = lesson.project_id; learnedFrom.value = lesson.learned_from ?? []; + const confirmed = (lesson.rules ?? []).filter((r) => r.state === "confirmed"); + linkedRules.value = confirmed; + const ids = confirmed.map((r) => r.id); + const why = lesson.no_rule?.why ?? ""; + const held: Answer = + lesson.rule_judgment === "linked" ? "rules" + : lesson.rule_judgment === "no_rule" ? "no_rule" + : "open"; + original.value = { answer: held, ruleIds: ids, why }; + selectedRuleIds.value = [...ids]; + noRuleWhy.value = why; + answer.value = held; } catch (e) { error.value = apiErrorMessage(e, "Failed to load this lesson"); } finally { @@ -105,12 +226,22 @@ async function save(force = false) { tags: tags.value, learned_from: learnedFrom.value, project_id: projectId.value, + ...answerPayload(), ...(force ? { force: true } : {}), }; const saved = isEdit.value && lessonId.value !== null ? await updateLesson(lessonId.value, payload) : await createLesson(payload); toast.show(isEdit.value ? "Lesson updated" : "Lesson recorded"); + // "No rule fits" just completed a group of lessons in one situation (#4634). + // Said once, here, because the page this goes to does not recompute it. + if (saved.convergence) { + toast.show( + `${saved.convergence.lessons.length} lessons now say no rule fits this ` + + "situation. A situation met this often may want a rule of its own.", + "warning", + ); + } router.push(`/lessons/${saved.id}`); } catch (e) { // A 409 is the duplicate gate, not a failure: it hands back the record @@ -238,6 +369,70 @@ onMounted(() => { +
+ Which rule is this an instance of? + + A confirmed rule surfaces through this lesson whenever its situation + comes up. Saying no rule fits is an answer too: lessons that keep + landing in one situation with no rule are how a missing rule gets + noticed. + + + +
+

Looking for rules it resembles…

+

+ Rule search is unavailable right now. +

+

+ No rule resembles this lesson closely. If none fits, say so below. +

+ + +
+ + +