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. +

+ + +
+ + +