diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 56904b47..d86579ba 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -296,8 +296,18 @@ jobs: set -eux echo "=== container landscape (diagnostic for the name filter) ===" docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' - PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg17" -q | head -n1) - test -n "$PG" + # Only THIS job's services. The runner's docker daemon is shared, so another + # repo's job can be running beside this one, and a job-name filter matches its + # containers too (Steward run 8358 and Inkwell run 8653 each took another repo's + # Postgres). act names every container of a task GITEA-ACTIONS-TASK--...: read + # from our own container (its id is in the /etc/hostname bind mount) and + # require exactly one match. + SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true) + SELF=${SELF:-$(hostname)} + TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-') + test -n "$TASK" + PG=$(docker ps --filter "name=$TASK" --filter "ancestor=pgvector/pgvector:pg17" -q) + test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1 PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") test -n "$PG_IP" export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test" diff --git a/alembic/versions/0121_retire_report_preference.py b/alembic/versions/0121_retire_report_preference.py new file mode 100644 index 00000000..1f34195c --- /dev/null +++ b/alembic/versions/0121_retire_report_preference.py @@ -0,0 +1,59 @@ +"""retire the report_preference arm's rows (milestone 500 step 4) + +Revision ID: 0121 +Revises: 0120 +Create Date: 2026-10-09 + +The fixed-question arm that searched for completion-report preferences when a +task closed is gone: the reply shapes and the preferences mounted beside them +now arrive on the moments (milestone 500 step 3). Its rows go with it (rule +22 — no legacy to preserve), because each one would otherwise mean something +different once the arm is no longer registered: + +- `retrieval_logs` / `retrieval_judgments` — a source missing from the + registry reads as `unregistered_source`, whose advice is "add it to the + registry": the opposite of what happened. +- `rule_usage_events` — a source missing from `RANKED_SOURCES` reads as + ambient, so its surfacings would silently move into the other column of + every rule's pull-through. +- `retrieval_tuning_events` — the tuning history of a surface that no longer + exists. +- `settings` — the arm's floor and budget keys, which nothing reads. + +COST IS BOUNDED BY AN INDEX, NOT BY THE TABLE. A migration runs at boot +against the live tables, which CI never has (lesson #5221). `retrieval_logs` +and `retrieval_judgments` are indexed on `source`. `rule_usage_events` is not, +so its delete is also bounded by `created_at` (indexed): the arm shipped with +milestone 409 step 4, decided 2026-09-14, so no row of it predates +`ARM_BORN`, and the scan covers weeks rather than the table's whole history. + +Not reversible: the arm is not coming back, so neither are its rows. +""" +from alembic import op + +revision = "0121" +down_revision = "0120" +branch_labels = None +depends_on = None + +SOURCE = "report_preference" +# A safe floor under the arm's first row (it shipped after 2026-09-14). +ARM_BORN = "2026-09-01" + + +def upgrade() -> None: + op.execute(f"DELETE FROM retrieval_logs WHERE source = '{SOURCE}'") + op.execute(f"DELETE FROM retrieval_judgments WHERE source = '{SOURCE}'") + op.execute( + f"DELETE FROM rule_usage_events WHERE created_at >= '{ARM_BORN}' " + f"AND source = '{SOURCE}'" + ) + op.execute(f"DELETE FROM retrieval_tuning_events WHERE surface = '{SOURCE}'") + op.execute( + "DELETE FROM settings WHERE key IN " + "('kb_reportpref_threshold', 'kb_reportpref_top_k')" + ) + + +def downgrade() -> None: + pass 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); + +