From 69dddb7551b8ca3a0686e9d8d3dc458067840052 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 9 Oct 2026 16:19:45 -0400 Subject: [PATCH] feat(500): Settings shows the shipped reply shapes, what adjusts each, and how often each arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings > General > Reply shapes. One menu row per shape (core, completion, asks, plan): its title and when it arrives, how many of the operator's preferences adjust it, and its deliveries over 30 days (in full / as a reminder, "—" when the counts cannot be read). A verdict line leads. Opening a row shows the shipped text, read-only because it is product, and the preferences mounted on its moment; each opens in the rule editor, and "Adjust this" opens a new preference already mounted on the shape's moment with a starting trigger. The rule editor takes an optional preset (kind, moments, trigger) and, opened outside the Rules view, asks where a new record lives instead of dropping it. GET /api/retrieval/reply-shapes now returns reply_shapes.overview: the catalog plus mounted preferences (the same lookup that delivers them) and counts from the reply_shape delivery rows. #5497 (step 5 of milestone 500). Co-Authored-By: Claude Opus 5.5 --- frontend/src/api/replyShapes.ts | 47 ++++ frontend/src/api/rulebooks.ts | 9 + .../src/components/ReplyShapesSettings.vue | 226 ++++++++++++++++++ .../components/rules/RuleEditorSlideOver.vue | 60 ++++- frontend/src/views/SettingsView.vue | 10 + src/scribe/routes/retrieval.py | 12 +- src/scribe/services/reply_shapes.py | 74 ++++++ tests/test_reply_shapes.py | 83 ++++++- 8 files changed, 505 insertions(+), 16 deletions(-) create mode 100644 frontend/src/api/replyShapes.ts create mode 100644 frontend/src/components/ReplyShapesSettings.vue diff --git a/frontend/src/api/replyShapes.ts b/frontend/src/api/replyShapes.ts new file mode 100644 index 00000000..27b4832b --- /dev/null +++ b/frontend/src/api/replyShapes.ts @@ -0,0 +1,47 @@ +import { apiGet } from "@/api/client"; + +/** + * The reply shapes Scribe ships (milestone 500) — `GET /api/retrieval/reply-shapes`. + * The same catalog the `list_reply_shapes` MCP tool returns, plus what only + * the operator's view needs: the preferences mounted on each shape's moment, + * and how often each shape was delivered. + */ +export interface ShapePreference { + id: number; + title: string; + statement: string; +} + +/** How often a shape went out: whole, or as its one-line reminder. */ +export interface ShapeDeliveries { + full: number; + pointer: number; +} + +export interface ReplyShape { + key: string; + title: string; + /** The moment it rides — a preference mounted here adjusts it. */ + moment: string; + /** What is happening at that moment. */ + means: string; + /** When the shape arrives, in words. */ + delivered: string; + /** The shipped text. Product, so read-only. */ + text: string; + preferences: ShapePreference[]; + /** null when the counts could not be read — unknown, not zero. */ + deliveries: ShapeDeliveries | null; +} + +export interface ReplyShapesOverview { + shapes: ReplyShape[]; + total: number; + days: number; + deliveries_failed: boolean; +} + +export async function getReplyShapes(days?: number): Promise { + const q = days ? `?days=${days}` : ""; + return apiGet(`/api/retrieval/reply-shapes${q}`); +} diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index e84f10c0..f4552855 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -54,6 +54,15 @@ export interface RulebookTopic { */ export type RuleKind = "rule" | "preference"; +/** A new record opened from somewhere that already knows what it is for — + * "Adjust this" on a reply shape opens a preference mounted on that shape's + * moment (milestone 500 step 5). The editor applies it once, when creating. */ +export interface RulePreset { + kind?: RuleKind; + moments?: string[]; + whenToApply?: string; +} + export interface Rule { id: number; topic_id: number | null; diff --git a/frontend/src/components/ReplyShapesSettings.vue b/frontend/src/components/ReplyShapesSettings.vue new file mode 100644 index 00000000..1ba91beb --- /dev/null +++ b/frontend/src/components/ReplyShapesSettings.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue index 530cfd9a..fe43f691 100644 --- a/frontend/src/components/rules/RuleEditorSlideOver.vue +++ b/frontend/src/components/rules/RuleEditorSlideOver.vue @@ -5,9 +5,14 @@ import { useCanonicalSystemsStore } from "@/stores/canonicalSystems"; import { useMomentsStore } from "@/stores/moments"; import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue"; import RuleHomePicker from "@/components/rules/RuleHomePicker.vue"; -import type { Rule, RuleKind } from "@/api/rulebooks"; +import { apiErrorMessage } from "@/api/client"; +import { listRulebooks, listTopics, type Rule, type RuleKind, type RulePreset } from "@/api/rulebooks"; -const props = defineProps<{ ruleId: number | null; topicId: number | null }>(); +const props = defineProps<{ + ruleId: number | null; + topicId: number | null; + preset?: RulePreset | null; +}>(); const emit = defineEmits<{ close: [] }>(); const store = useRulebooksStore(); @@ -128,10 +133,10 @@ async function load() { } else { title.value = ""; statement.value = ""; - whenToApply.value = ""; - kind.value = "rule"; + whenToApply.value = props.preset?.whenToApply ?? ""; + kind.value = props.preset?.kind ?? "rule"; systemIds.value = []; - ruleMoments.value = []; + ruleMoments.value = [...(props.preset?.moments ?? [])]; why.value = ""; howToApply.value = ""; verifyWith.value = ""; @@ -139,7 +144,31 @@ async function load() { } procedureDraft.value = ""; procedureError.value = ""; - await Promise.all([canon.fetchCatalog(), momentsStore.load()]); + await Promise.all([canon.fetchCatalog(), momentsStore.load(), loadHomes()]); +} + +// ── Where a new record lives, when the opener did not say ────────────────── +// The rules view opens the editor inside a topic; Settings does not, so a new +// record opened there asks for its home. A rulebook topic is global — it +// applies in every project, as a reply shape does. +const homeChoices = ref<{ id: number; label: string }[]>([]); +const chosenTopic = ref(null); +const homeError = ref(""); +const needsHome = computed(() => isCreating.value && props.topicId === null); + +async function loadHomes() { + if (!needsHome.value || homeChoices.value.length) return; + try { + const books = await listRulebooks(); + const perBook = await Promise.all(books.map(async (rb) => { + const ts = await listTopics(rb.id); + return ts.map((t) => ({ id: t.id, label: `${rb.title} › ${t.title}` })); + })); + homeChoices.value = perBook.flat(); + if (homeChoices.value.length === 1) chosenTopic.value = homeChoices.value[0].id; + } catch (e: unknown) { + homeError.value = apiErrorMessage(e, "Could not load where this could live"); + } } async function save() { @@ -166,8 +195,14 @@ async function save() { verify_with: verifyWith.value, expires_when: expiresWhen.value, }; - if (isCreating.value && props.topicId !== null) { - await store.createRule(props.topicId, fields); + const home = props.topicId ?? chosenTopic.value; + if (isCreating.value && home === null) { + // Written but homeless: keep the draft open rather than lose it on close. + homeError.value = "Choose where it lives before saving."; + return; + } + if (isCreating.value && home !== null) { + await store.createRule(home, fields); } else if (props.ruleId !== null) { await store.updateRule(props.ruleId, fields); } @@ -201,6 +236,14 @@ watch(() => props.ruleId, load); + +