From 9c95e713d2c848a39d8cfea198491e53afd0ad38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 23 Jul 2026 22:49:54 +0200 Subject: [PATCH 1/3] feat(coach): typed grounding ledger + four-rung action ladder (Guard II) Promote the per-turn magnitude bag to a per-conversation typed Grounding Ledger built from every source the model was actually shown: this turn's tool payloads, prior-turn tool figures, coach-memory goals, the medication schedule, the reference-grounding block, and guided blocks. Assistant prose is never a registration source, so a figure that slipped a rung cannot launder into an authoritative value on the next turn. Complete the action ladder: a reconciled number passes, a reference or education sentence is exempt across all six locales, a positively-typed benign number (date, time, year, ordinal, list marker) passes while an untyped unreconciled one stays on the strip path, and an unreconciled health figure is soft-stripped. Gate the dose continuation exemption on the user's actual schedule: a "keep taking your N mg" is trusted only when N is a dose the user is on; an off-schedule maintenance dose is caught. Extend the risk and percent classifiers to fr/es/it/pl to match the EN/DE depth. No schema migration: the ledger is rebuilt per turn from persisted tool traces and re-rendered prompt blocks. --- .../chat/__tests__/route-guard-ledger.test.ts | 336 +++++++++++++++++ src/app/api/insights/chat/route.ts | 109 ++++-- .../coach-prose-grounding-ledger.test.ts | 133 +++++++ .../coach/__tests__/grounding-ledger.test.ts | 139 +++++++ src/lib/ai/coach/coach-prose-grounding.ts | 350 +++++++++++++++--- src/lib/ai/coach/eval/golden-cases.ts | 14 +- src/lib/ai/coach/grounding-ledger.ts | 176 +++++++++ src/lib/ai/coach/outbound-guard.ts | 11 +- src/lib/ai/coach/persistence.ts | 18 +- src/lib/ai/coach/types.ts | 10 + .../__tests__/outbound-screen-guard2.test.ts | 123 ++++++ src/lib/ai/safety/outbound-screen.ts | 135 ++++++- src/lib/medications/scheduled-doses.ts | 46 +++ 13 files changed, 1506 insertions(+), 94 deletions(-) create mode 100644 src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts create mode 100644 src/lib/ai/coach/__tests__/coach-prose-grounding-ledger.test.ts create mode 100644 src/lib/ai/coach/__tests__/grounding-ledger.test.ts create mode 100644 src/lib/ai/coach/grounding-ledger.ts create mode 100644 src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts create mode 100644 src/lib/medications/scheduled-doses.ts diff --git a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts new file mode 100644 index 000000000..b690a690a --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.32.9 (Coach Guard II / G2) — the Grounding Ledger is CROSS-TURN, so its + * two load-bearing properties can only be seen at the route level, over a + * conversation with prior turns (the #591 lesson — the pure module cannot see + * the route's activation + cross-turn assembly): + * + * - a prior-turn TOOL figure (persisted as `groundedFigures`) recalled this + * turn reconciles and is NOT stripped; + * - a number that appears only in a prior ASSISTANT REPLY (never a tool figure) + * does NOT ground the next turn — assistant prose is never a ledger source + * (D3 / red-team H3). It is still stripped. + * + * Guards are REAL here (coach-prose-grounding + grounding-ledger unmocked); the + * provider loop + persistence are stubbed, exactly like route-tool-mode. + */ + +const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}'; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + isModuleEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn(async () => undefined), +})); +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number) => ({ data: null, error, status }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(), +})); +vi.mock("@/lib/logging/redact", () => ({ + redactSecrets: (s: string) => s, + redactOptional: (s: unknown) => s, +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) }, + coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) }, + }, +})); + +const { checkRateLimit } = vi.hoisted(() => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn(async () => "en"), +})); + +const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({ + runStreamingRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runStreamingRawCompletionWithFallback, +})); + +const { resolveProviderChain } = vi.hoisted(() => ({ + resolveProviderChain: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain, + resolveProvider: vi.fn(), +})); + +const { assertConsentForChain } = vi.hoisted(() => ({ + assertConsentForChain: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain })); +vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +vi.mock("@/lib/ai/ai-budgets", () => ({ + AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } }, +})); + +vi.mock("@/lib/ai/coach/types", async () => { + const actual = await vi.importActual( + "@/lib/ai/coach/types", + ); + return actual; +}); + +const { fetchConversationWithMessages } = vi.hoisted(() => ({ + fetchConversationWithMessages: vi.fn(), +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(async () => ({ id: "m1" })), + createConversation: vi.fn(async () => ({ id: "c1" })), + fetchConversationWithMessages, + listConversations: vi.fn(), +})); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ + enqueueCoachMemoryRefresh: vi.fn(), +})); +vi.mock("@/lib/ai/coach/facts", () => ({ + storeDeterministicFacts: vi.fn(async () => undefined), +})); + +// Guard II — the schedule read. Empty here; the schedule-gated dose rule is +// unit-tested in the outbound-screen suite. +vi.mock("@/lib/medications/scheduled-doses", () => ({ + getScheduledDoseValues: vi.fn(async () => []), +})); + +const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({ + reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })), + reconcileSpend: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-07-23"), + reserveBudget, + reconcileSpend, + resolveDailyCap: vi.fn(() => 2_000_000), +})); + +const { detectRefusal } = vi.hoisted(() => ({ + detectRefusal: vi.fn(() => ({ refuse: false })), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal })); +vi.mock("@/lib/ai/coach/outbound-guard", () => ({ + screenCoachReply: vi.fn(() => ({ block: false })), + coachOutboundFallback: vi.fn(() => "fallback"), +})); +vi.mock("@/lib/ai/coach/system-prompt", () => ({ + getCoachSystemPrompt: vi.fn(() => "SYSTEM"), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: vi.fn(async () => ({ + snapshotJson: SNAPSHOT_JSON, + sections: { bloodPressure: { aggregate: { mean: 128 } } }, + provenance: { windows: ["last30days"], metrics: ["bp"] }, + referenceGrounding: "REFERENCE RANGES", + })), +})); +vi.mock("@/lib/workouts/hr-series", () => ({ + buildWorkoutHrSeries: vi.fn(async () => null), +})); +vi.mock("@/lib/workouts/zones", () => ({ + computeZones: vi.fn(() => null), + hrMaxFromAge: vi.fn(() => 185), + parseWhoopZoneDurations: vi.fn(() => null), +})); +vi.mock("@/lib/workouts/sport-context", () => ({ + buildSportContext: vi.fn(async () => null), +})); + +const { + buildCoachDataInventory, + renderDataInventory, + renderFocusHint, + runCoachToolLoop, +} = vi.hoisted(() => ({ + buildCoachDataInventory: vi.fn(async () => ({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: ["bp"], window: "last30days" }, + })), + renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"), + renderFocusHint: vi.fn(() => ""), + runCoachToolLoop: vi.fn(), +})); +vi.mock("@/lib/ai/coach/tools", () => ({ + COACH_TOOL_DEFS: [{ name: "get_metric_series" }], + MAX_ROUNDS: 3, + buildCoachDataInventory, + renderDataInventory, + renderFocusHint, + buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"), + runCoachToolLoop, +})); + +const { parseKeyValuesSentinel } = vi.hoisted(() => ({ + parseKeyValuesSentinel: vi.fn(), +})); +vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel })); +const { parseSuggestReminder } = vi.hoisted(() => ({ + parseSuggestReminder: vi.fn(), +})); +vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder })); +vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() })); +vi.mock("@/lib/validations/coach-prefs", () => ({ + parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })), + DEFAULT_REMINDER_SUGGESTION_PREFS: {}, +})); + +const { appendMessage } = await import("@/lib/ai/coach/persistence"); + +const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise })); +vi.mock("@/lib/sse/create-stream", () => ({ + createSseStream: ( + producer: (c: { + signal: { aborted: boolean }; + enqueue: () => void; + }) => void | Promise, + ) => { + sse.done = Promise.resolve( + producer({ signal: { aborted: false }, enqueue: () => {} }), + ); + return new ReadableStream(); + }, +})); + +import { POST } from "../route"; + +const post = POST as unknown as (req: Request) => Promise; + +function chatReq(body: Record): Request { + return new Request("http://localhost/api/insights/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** Force the model's assembled prose through the sentinel/suggest parsers. */ +function stubReply(prose: string, toolResults: unknown[]): void { + parseKeyValuesSentinel.mockReturnValue({ + prose, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + parseSuggestReminder.mockReturnValue({ prose }); + runCoachToolLoop.mockImplementation(async () => ({ + result: { content: prose, tokensUsed: 80, model: "m" }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 1, + toolTrace: [{ name: "get_metric_series", present: true }], + toolResults, + })); +} + +function assistantContent(): string { + const calls = (appendMessage as ReturnType).mock.calls; + const assistant = calls.find( + (c) => (c[0] as { role: string }).role === "assistant", + ); + return (assistant?.[0] as { content: string }).content; +} + +describe("coach chat — cross-turn Grounding Ledger (G2)", () => { + beforeEach(() => { + vi.clearAllMocks(); + reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 }); + detectRefusal.mockReturnValue({ refuse: false }); + checkRateLimit.mockResolvedValue({ allowed: true }); + assertConsentForChain.mockResolvedValue(undefined); + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + }); + + it("recalls a PRIOR-turn tool figure (persisted groundedFigures) without stripping it", async () => { + // Turn 1 fetched systolic 128 and persisted it as groundedFigures. + fetchConversationWithMessages.mockResolvedValue({ + id: "c1", + attachmentCount: 0, + summary: null, + messages: [ + { role: "user", content: "How is my BP?" }, + { + role: "assistant", + content: "Your systolic averaged 128.", + metricSource: { groundedFigures: [128] }, + }, + ], + }); + // This turn: a FRESH sleep tool activates the verifier; the model recalls + // the prior 128 AND cites the fresh 440. + stubReply( + "Your sleep averaged 440 minutes, and your systolic 128 average still holds.", + [{ present: true, data: { metric: "sleep", aggregate: { mean: 440 } } }], + ); + + await post(chatReq({ conversationId: "c1", message: "and my sleep?" })); + await sse.done; + + const content = assistantContent(); + expect(content).toContain("128"); + expect(content).toContain("440"); + expect(content).not.toContain("[unverified]"); + }); + + it("does NOT let a number from a prior ASSISTANT REPLY ground the next turn (D3 / H3)", async () => { + // The prior assistant reply contains 158, but the persisted tool figure was + // 128 — 158 was never a tool figure (a rung leak / fabrication). The ledger + // must not read the assistant prose, so 158 stays ungrounded and is stripped. + fetchConversationWithMessages.mockResolvedValue({ + id: "c1", + attachmentCount: 0, + summary: null, + messages: [ + { role: "user", content: "How is my BP?" }, + { + role: "assistant", + content: "Your systolic spike to 158 mmHg is worth watching.", + metricSource: { groundedFigures: [128] }, + }, + ], + }); + // This turn: a fresh BP tool returns the real 128; the model repeats the + // fabricated 158. + stubReply("Your systolic spike to 158 mmHg persists this week.", [ + { present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } }, + ]); + + await post(chatReq({ conversationId: "c1", message: "still high?" })); + await sse.done; + + const content = assistantContent(); + expect(content).not.toContain("158"); + expect(content).toContain("[unverified]"); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 15c6867f5..c9a68e4c5 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -103,9 +103,14 @@ import { } from "@/lib/ai/coach/tools"; import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues"; import { - findUnverifiedCoachNumbers, + findUnverifiedCoachNumbersInLedger, stripUnverifiedNumbers, } from "@/lib/ai/coach/coach-prose-grounding"; +import { + buildGroundingLedger, + figuresForPersistence, +} from "@/lib/ai/coach/grounding-ledger"; +import { getScheduledDoseValues } from "@/lib/medications/scheduled-doses"; import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard"; import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder"; import { gateSuggestion } from "@/lib/ai/coach/suggest-gate"; @@ -270,6 +275,14 @@ async function handleChatRequest(request: NextRequest): Promise { // ── Conversation resolution ────────────────────────────────── let workingConversationId: string; let priorTurns: CoachTurn[] = []; + // v1.32.9 (Coach Guard II / G2) — cross-turn Grounding Ledger sources. Prior + // USER messages (numbers the user themselves stated) and the persisted + // per-turn tool figures of prior turns (`groundedFigures` — server-computed + // magnitudes, NEVER the assistant's prose, per D3). Both feed reconciliation + // so a figure recalled a turn later is not stripped, without a laundering + // path through assistant narration. + let priorUserMessages: string[] = []; + let priorToolFigures: number[] = []; // v1.11.1 — rolling summary of the elided older turns, read stale-while- // revalidate; null for a fresh conversation or when none is on file. let priorSummary: string | null = null; @@ -316,6 +329,16 @@ async function handleChatRequest(request: NextRequest): Promise { role: m.role, content: m.content, })); + // Ledger cross-turn sources (D3-safe): user-authored numbers + the + // persisted tool figures of prior turns. Assistant PROSE is never read. + priorUserMessages = existing.messages + .filter((m) => m.role === "user") + .map((m) => m.content); + priorToolFigures = existing.messages.flatMap((m) => + m.metricSource?.groundedFigures + ? [...m.metricSource.groundedFigures] + : [], + ); // v1.4.43 W13 M-3 — replay-injection guard. `detectRefusal` runs // only on the inbound `message` per turn, so an injection that @@ -409,6 +432,11 @@ async function handleChatRequest(request: NextRequest): Promise { // v1.16.0 — composed self-context: structured questionnaire fields // plus age/gender merged in from the User profile. const aboutMe = await getSelfContextTextForUser(userId, locale); + // v1.32.9 (Coach Guard II / G3) — the user's active medication doses, for the + // Grounding Ledger's `schedule` source AND the schedule-gated dose + // continuation exemption. Fail-open: a read failure yields an empty set, which + // keeps the Guard I phrase-anchored dose behaviour. + const scheduleDoses = await getScheduledDoseValues(userId).catch(() => []); // v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so // the two memory/action clauses are appended at assembly time here: one // teaches the model to emit `---REMEMBER---` (durable "remind me" capture), @@ -890,7 +918,7 @@ async function handleChatRequest(request: NextRequest): Promise { // the system-prompt GLP-1/grounding contracts. On a trip the turn is // replaced with a calm, grounded fallback and any reminder suggestion / // key-value provenance is dropped — the user never sees the unsafe text. - const outbound = screenCoachReply(replyText, locale); + const outbound = screenCoachReply(replyText, locale, scheduleDoses); if (outbound.block && outbound.reason) { replyText = coachOutboundFallback(outbound.reason, locale); annotate({ @@ -919,32 +947,61 @@ async function handleChatRequest(request: NextRequest): Promise { // (`snapshot.sections`, which already carries the correlations-snapshot block). // Exactly one is populated per turn; the grading, tolerance, and exemptions are // identical, so a number the model invents is flagged the same way on both. - const authoritativePayloads = + // v1.32.9 (Coach Guard II / G2+G3) — grade the prose against the typed + // Grounding Ledger rather than a per-turn magnitude bag. Activation is + // unchanged from Guard I: the ledger is graded ONLY when THIS turn delivered + // fresh figures the model was told to ground against (a present tool result + // / pinned workout, or the full snapshot on the no-tools path). The + // cross-turn, memory, schedule, reference, and guided sources only WIDEN an + // active grading — they never ACTIVATE it, so a snapshot figure the model + // cited on a no-tool turn is still left alone (the v1.32.1 regression guard + // holds). Assistant prose is never a ledger source (D3). + const activatingPayloads = toolResultPayloads.length > 0 ? toolResultPayloads : noToolsSnapshotPayloads; - if (!outbound.block && authoritativePayloads.length > 0) { - const unverified = findUnverifiedCoachNumbers( - replyText, - authoritativePayloads, - locale, - ); - if (unverified.length > 0) { - const { prose: corrected, stripped } = stripUnverifiedNumbers( + let groundedFigures: number[] = []; + if (activatingPayloads.length > 0) { + const ledger = buildGroundingLedger({ + toolPayloads: activatingPayloads, + priorToolFigures, + priorUserMessages, + // The self-context (goals / about-me) rides the system prompt on both + // paths — the ledger's `memory` source. + memoryTexts: aboutMe ? [aboutMe] : [], + scheduleDoses, + // Reference grounding rides the prompt only on the no-tools + // full-snapshot path; the guided block rides both paths. + referenceGrounding: + !toolMode && includeFullSnapshot ? snapshot.referenceGrounding : null, + guidedBlock: turnContext.guidedBlock, + }); + // Persist THIS turn's tool figures (bare magnitudes) so a LATER turn can + // recall them via the ledger — D3: the tool trace, never the prose. + groundedFigures = figuresForPersistence(ledger); + if (!outbound.block) { + const unverified = findUnverifiedCoachNumbersInLedger( replyText, - unverified, + ledger, + locale, ); - replyText = corrected; - annotate({ - action: { name: "coach.prose.number_unverified" }, - meta: { - flagged: unverified.length, - stripped, - // No raw values — just the count + truncated tokens for ops triage. - tokens: unverified.slice(0, 6).map((u) => u.source), - promptVersion: PROMPT_VERSION, - }, - }); + if (unverified.length > 0) { + const { prose: corrected, stripped } = stripUnverifiedNumbers( + replyText, + unverified, + ); + replyText = corrected; + annotate({ + action: { name: "coach.prose.number_unverified" }, + meta: { + flagged: unverified.length, + stripped, + // No raw values — just the count + truncated tokens for ops triage. + tokens: unverified.slice(0, 6).map((u) => u.source), + promptVersion: PROMPT_VERSION, + }, + }); + } } } @@ -1049,6 +1106,12 @@ async function handleChatRequest(request: NextRequest): Promise { })), } : {}), + // v1.32.9 (Coach Guard II / G2) — persist THIS turn's tool figures so a + // later turn's Grounding Ledger can recall them (D3-safe: tool trace, not + // prose). Dropped on a blocked turn (its figures rode the discarded reply). + ...(!outbound.block && groundedFigures.length > 0 + ? { groundedFigures } + : {}), }; if (sentinel.malformed) { // Graceful degrade: log so ops can spot a provider whose diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding-ledger.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding-ledger.test.ts new file mode 100644 index 000000000..af8d953dd --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding-ledger.test.ts @@ -0,0 +1,133 @@ +/** + * v1.32.9 (Coach Guard II / G2+G3) — grading the Coach prose against the typed + * Grounding Ledger, and the four-rung action ladder. + * + * Both directions, as the release mandate requires: + * - GROUNDED-PASSES: a prior-turn tool figure recalled this turn; a coach-memory + * goal number; a real medication-schedule dose; a reference/education + * sentence (rung 2). + * - FABRICATED-BLOCKS: an untyped cross-sentence fabrication is NOT waved + * through by rung 3 (D2/H2); a number the ledger never learned is flagged + * (the D3 unit half — the assistant-prose laundering repro lives in the + * route SSE test, where the ledger is built across turns). + */ +import { describe, expect, it } from "vitest"; + +import { + findUnverifiedCoachNumbersInLedger, + registerPayloadEntries, + registerScalarEntries, + registerTextEntries, + type LedgerEntry, +} from "@/lib/ai/coach/coach-prose-grounding"; + +describe("findUnverifiedCoachNumbersInLedger — grounded passes", () => { + it("passes a prior-turn tool figure recalled this turn (transcript:tool-trace)", () => { + // Turn 1 fetched systolic 128; this turn the model recalls it while a fresh + // tool (sleep) activated the verifier. The 128 lives in the ledger as a + // prior tool figure — not stripped. + const ledger: LedgerEntry[] = [ + ...registerPayloadEntries( + { metric: "sleep", aggregate: { mean: 440 } }, + "tool:this-turn", + ), + ...registerScalarEntries([128], "transcript:tool-trace"), + ]; + const prose = + "Your sleep held around 440 minutes, and the 128 average we discussed still holds."; + expect(findUnverifiedCoachNumbersInLedger(prose, ledger)).toEqual([]); + }); + + it("passes a coach-memory goal number (memory source)", () => { + const ledger: LedgerEntry[] = [ + ...registerPayloadEntries( + { aggregate: { latest: 87 } }, + "tool:this-turn", + ), + ...registerTextEntries("your goal of 85 kg", "memory"), + ]; + const prose = "At 87 kg you're closing in on your goal of 85 kg."; + expect(findUnverifiedCoachNumbersInLedger(prose, ledger)).toEqual([]); + }); + + it("passes a real medication-schedule dose (schedule source)", () => { + const ledger: LedgerEntry[] = [ + ...registerPayloadEntries( + { aggregate: { latest: 88 } }, + "tool:this-turn", + ), + ...registerScalarEntries([7.5], "schedule", "dose"), + ]; + const prose = "You're steady at 88 kg while staying on your 7.5 mg dose."; + expect(findUnverifiedCoachNumbersInLedger(prose, ledger)).toEqual([]); + }); + + it("exempts a reference/education sentence at rung 2 (population-norm framed)", () => { + // The ledger is active (a fresh tool figure) but the education sentence's + // 7–9 hours are a population norm, not a claim about this user's data. + const ledger = registerPayloadEntries( + { metric: "sleep", aggregate: { mean: 400 } }, + "tool:this-turn", + ); + const prose = + "Your sleep averaged 400 minutes. Adults generally need 7 to 9 hours a night."; + expect(findUnverifiedCoachNumbersInLedger(prose, ledger)).toEqual([]); + }); + + it("exempts a German reference sentence (six-locale rung 2)", () => { + const ledger = registerPayloadEntries( + { metric: "pulse", aggregate: { mean: 62 } }, + "tool:this-turn", + ); + const prose = + "Dein Ruhepuls liegt bei 62. Die normale Ruheherzfrequenz liegt bei 60 bis 100."; + expect(findUnverifiedCoachNumbersInLedger(prose, ledger, "de")).toEqual([]); + }); +}); + +describe("findUnverifiedCoachNumbersInLedger — fabricated blocks", () => { + it("flags an untyped cross-sentence fabrication (rung 3 does NOT pass untyped — D2/H2)", () => { + // avgSys30 128; the '158 spike' has no unit and no adjacent metric noun — + // exactly the shape the naive 'health-shaped only' rung-3 would have waved + // through. It must still be flagged. + const ledger = registerPayloadEntries( + { metric: "bp", aggregate: { avgSys30: 128 } }, + "tool:this-turn", + ); + const prose = + "Your blood pressure looks stable overall. That said, the spike to 158 last Tuesday is worth watching."; + const findings = findUnverifiedCoachNumbersInLedger(prose, ledger); + expect(findings.map((f) => f.value)).toContain(158); + }); + + it("flags a number the ledger never learned (assistant prose is not a source — D3 unit half)", () => { + // The ledger is built ONLY from tool trace + user/memory/schedule — never + // from a prior assistant reply. A figure that appears only in prior + // narration is absent from the ledger, so restating it here is flagged. + const ledger = registerPayloadEntries( + { metric: "bp", aggregate: { avgSys30: 128 } }, + "tool:this-turn", + ); + const prose = "Your systolic spike to 158 mmHg is the one to watch."; + const findings = findUnverifiedCoachNumbersInLedger(prose, ledger); + expect(findings.map((f) => f.value)).toContain(158); + }); + + it("still catches a genuine transcription drift (Guard I invariant holds)", () => { + const ledger = registerPayloadEntries( + { metric: "bp", aggregate: { avgSys30: 128 } }, + "tool:this-turn", + ); + const findings = findUnverifiedCoachNumbersInLedger( + "Your systolic averaged about 138 lately.", + ledger, + ); + expect(findings.map((f) => f.value)).toContain(138); + }); + + it("no-ops on an empty ledger (nothing shown to grade against)", () => { + expect( + findUnverifiedCoachNumbersInLedger("Your systolic averaged 138.", []), + ).toEqual([]); + }); +}); diff --git a/src/lib/ai/coach/__tests__/grounding-ledger.test.ts b/src/lib/ai/coach/__tests__/grounding-ledger.test.ts new file mode 100644 index 000000000..4dc53ff50 --- /dev/null +++ b/src/lib/ai/coach/__tests__/grounding-ledger.test.ts @@ -0,0 +1,139 @@ +/** + * v1.32.9 (Coach Guard II / G2) — the typed Grounding Ledger. + * + * Two things are load-bearing here: + * - D9 structural completeness: every declared registration source actually + * contributes. A source added to `LEDGER_SOURCES` without being wired into + * `buildGroundingLedger` fails this test rather than silently shipping + * ungraded figures — the briefing "walk, don't hand-list" invariant. + * - D3 persistence boundary: only tool-trace / inventory / workout / snapshot + * figures are persisted for cross-turn recall; the cross-turn, memory, + * schedule, reference, and guided sources are NOT re-persisted (and assistant + * prose is never a source at all). + */ +import { describe, expect, it } from "vitest"; + +import { + buildGroundingLedger, + figuresForPersistence, + LEDGER_SOURCES, +} from "@/lib/ai/coach/grounding-ledger"; +import type { LedgerSource } from "@/lib/ai/coach/coach-prose-grounding"; + +/** + * One numeric fixture per source, chosen so every sentinel is distinct — the + * completeness assertion can then attribute each magnitude to exactly one + * source. This IS the D9 fixture: render every prompt-contributing module with + * numbers and assert each lands in the ledger under its own source tag. + */ +const SENTINEL: Record = { + "tool:this-turn": { + input: { toolPayloads: [{ aggregate: { mean: 128 } }] }, + value: 128, + }, + inventory: { input: { inventoryEntries: [{ count: 42 }] }, value: 42 }, + "workout-evidence": { + input: { workoutEvidence: { totalEnergyKcal: 411 } }, + value: 411, + }, + snapshot: { + input: { snapshotSections: { bp: { aggregate: { mean: 118 } } } }, + value: 118, + }, + "transcript:tool-trace": { + input: { priorToolFigures: [95] }, + value: 95, + }, + "transcript:user": { + input: { priorUserMessages: ["I weighed 77 kg this morning."] }, + value: 77, + }, + memory: { input: { memoryTexts: ["your goal of 85 kg"] }, value: 85 }, + schedule: { input: { scheduleDoses: [7.5] }, value: 7.5 }, + "reference-grounding": { + input: { referenceGrounding: "the normal range is 63 to 99 mg/dL" }, + value: 63, + }, + guided: { + input: { guidedBlock: "clarifying context around 33 units" }, + value: 33, + }, +}; + +describe("buildGroundingLedger — D9 structural completeness", () => { + it("declares every registration source in LEDGER_SOURCES exactly once", () => { + expect(new Set(LEDGER_SOURCES).size).toBe(LEDGER_SOURCES.length); + // The fixture map must cover the declared source list — adding a source + // without a fixture (or vice-versa) breaks this. + expect(new Set(Object.keys(SENTINEL))).toEqual(new Set(LEDGER_SOURCES)); + }); + + it("registers a numeric fixture from EVERY declared source under its own tag", () => { + // Build ONE ledger with every source populated at once, then assert each + // source contributed its sentinel. A source dropped from the builder makes + // its sentinel absent → this fails. + const combined = Object.values(SENTINEL).reduce( + (acc, { input }) => ({ ...acc, ...input }), + {}, + ); + const ledger = buildGroundingLedger(combined); + const emitted = new Set(ledger.map((e) => e.source)); + + for (const source of LEDGER_SOURCES) { + const { value } = SENTINEL[source]; + expect(emitted.has(source)).toBe(true); + const found = ledger.some( + (e) => e.source === source && Math.abs(e.value - value) < 1e-9, + ); + expect(found, `source ${source} did not register ${value}`).toBe(true); + } + }); + + it("tags a medication schedule dose with the dose kind", () => { + const ledger = buildGroundingLedger({ scheduleDoses: [7.5] }); + const entry = ledger.find((e) => e.value === 7.5); + expect(entry?.source).toBe("schedule"); + expect(entry?.kind).toBe("dose"); + }); + + it("registers both the signed and absolute form of a text-sourced number", () => { + // A reference band written "−1.2 to 1.2" must reconcile either narration. + const ledger = buildGroundingLedger({ + referenceGrounding: "a delta of -1.2 kg", + }); + const values = ledger.map((e) => e.value); + expect(values).toContain(-1.2); + expect(values).toContain(1.2); + }); +}); + +describe("figuresForPersistence — cross-turn recall boundary (D3)", () => { + it("persists this-turn tool / inventory / workout / snapshot figures", () => { + const ledger = buildGroundingLedger({ + toolPayloads: [{ aggregate: { mean: 128 } }], + inventoryEntries: [{ count: 42 }], + workoutEvidence: { totalEnergyKcal: 411 }, + }); + const figures = figuresForPersistence(ledger); + expect(figures).toContain(128); + expect(figures).toContain(42); + expect(figures).toContain(411); + }); + + it("does NOT re-persist cross-turn / memory / schedule / reference / guided figures", () => { + const ledger = buildGroundingLedger({ + priorToolFigures: [95], + priorUserMessages: ["I weighed 77 kg"], + memoryTexts: ["goal of 85 kg"], + scheduleDoses: [7.5], + referenceGrounding: "normal is 63 to 99", + guidedBlock: "context 33", + }); + const figures = figuresForPersistence(ledger); + // None of these should be persisted — they are re-derived every turn from + // their own live sources, never carried forward as "tool trace". + for (const v of [95, 77, 85, 7.5, 63, 99, 33]) { + expect(figures).not.toContain(v); + } + }); +}); diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index dd3dd144d..6c50827c5 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -1,6 +1,8 @@ /** * v1.21.0 (P6 / C2-5) — post-hoc numeric verifier for Coach prose. * v1.32.7 (Coach Guard I / G1) — typed tokenizer + normal-form reconciler. + * v1.32.9 (Coach Guard II / G2+G3) — the typed Grounding Ledger + the + * four-rung action ladder (the reference-sentence exemption in particular). * * The Daily Briefing strips any number absent from the server-computed * `signalsOfDay` block (`@/lib/ai/briefing-grounding`). The Coach's TOOL path @@ -15,7 +17,7 @@ * `10,000` → `10`, `60-100` → `60` / `-100`) and clipped a grounded number * inside a larger one (flagged `23` strips `20[unverified]` out of `2023`). * - * G1 replaces both halves: + * G1 replaced both halves: * * 1. A TYPED TOKENIZER classifies each prose number before it is graded — * ISO / written dates, clock times, bare years, ordinals, list markers, @@ -35,12 +37,29 @@ * sample, where the widenings would compound into a free band). Kind * scoping means a weight value can never ground a blood-pressure claim. * + * G2 promotes the per-turn authoritative bag to a typed, per-conversation + * GROUNDING LEDGER (`grounding-ledger.ts`) built at prompt-assembly time from + * every source the model was legitimately shown — this turn's tool payloads, + * PRIOR-turn tool figures, coach-memory goals, the medication schedule, the + * reference-grounding block and guided blocks. Assistant prose is NEVER a + * registration source (D3), so a figure that slips a rung cannot self-launder + * into authoritative on the next turn. + * + * G3 completes the FOUR-RUNG action ladder: + * rung 1 — reconciled → pass + * rung 2 — reference sentence → exempt (population-norm framed, six locales) + * rung 3 — benign type → pass (the tokenizer positively types + * dates / times / years / ordinals / lists; + * an untyped unreconciled number does NOT + * pass — it stays on the strip path, D2) + * rung 4 — unreconciled health → boundary-safe soft-strip + * * Posture: NON-BLOCKING and cheap. The caller annotates * `coach.prose.number_unverified` and may SOFT-STRIP the unverified figure * from the prose (boundary-safe replacement, so a grounded number inside a * larger token is never clipped) — it never hard-fails the user's turn. When - * NO tool returned figures (a qualitative answer, or the no-tools path) there - * is no authoritative set to grade against, so the check no-ops and the + * the ledger is empty (a qualitative answer, or the no-tools path with no + * fresh figures) there is nothing to grade against, so the check no-ops and the * prompt-level grounding rule remains the backstop, exactly like the briefing's * "no signals → skip". */ @@ -82,8 +101,15 @@ export interface UnverifiedCoachNumber { * kinding is a monotone tightening path: every kind added strictly improves * precision, never widens. * ────────────────────────────────────────────────────────────────────────── */ -type NumKind = - "mass" | "pressure" | "pulse" | "percent" | "duration" | "count" | "glucose"; +export type NumKind = + | "mass" + | "pressure" + | "pulse" + | "percent" + | "duration" + | "count" + | "glucose" + | "dose"; /** Resolve a kind from a unit suffix the prose attaches to a number. */ function unitToKind(unitRaw: string): NumKind | null { @@ -143,13 +169,35 @@ const AGGREGATE_POINT_KEY = /** Defensive cap on aggregate points contributed to the pairwise derivation. */ const MAX_AGGREGATE_POINTS = 12; -/** A typed authoritative figure the model was shown this turn. */ +/** + * The provenance a ledger entry was registered from. Named so the disagreement + * telemetry (D10) can attribute which source grounded a token, and so the + * structural completeness test (D9) can assert every known source contributes. + */ +export type LedgerSource = + | "tool:this-turn" + | "transcript:tool-trace" + | "transcript:user" + | "memory" + | "schedule" + | "reference-grounding" + | "guided" + | "snapshot" + | "inventory" + | "workout-evidence"; + +/** A typed authoritative figure the model was shown (kind + aggregation). */ interface AuthEntry { value: number; kind: NumKind | null; aggregation: "aggregate" | "sample"; } +/** A ledger entry — an `AuthEntry` tagged with the source it was registered from. */ +export interface LedgerEntry extends AuthEntry { + source: LedgerSource; +} + /** * Walk a single tool-result payload and register every finite numeric leaf as * a typed authoritative entry. `kind` is inferred from the payload's `metric` @@ -248,6 +296,77 @@ function deriveArithmeticEntries(points: readonly number[]): AuthEntry[] { return derived; } +/* ────────────────────────────────────────────────────────────────────────── + * Registration — the ledger's INPUT side. Every code path that renders a + * numeric fact into model-visible text registers it here, tagged with its + * source. Registration is the ONLY way a figure becomes authoritative; a + * source that is never wired in contributes nothing (D9 asserts completeness). + * ────────────────────────────────────────────────────────────────────────── */ + +/** + * Register a structured tool-result / snapshot / inventory / workout payload: + * every numeric leaf becomes a typed entry, plus the pairwise arithmetic + * derived from THIS payload's own aggregate points (never pooled across + * payloads). Used for every source the model was shown as JSON. + */ +export function registerPayloadEntries( + payload: unknown, + source: LedgerSource, +): LedgerEntry[] { + const entries: AuthEntry[] = []; + collectEntries(payload, entries, { + metricKind: null, + keyHint: null, + inArray: false, + }); + entries.push(...deriveArithmeticEntries(collectAggregatePoints(entries))); + return entries.map((e) => ({ ...e, source })); +} + +/** + * Register the numbers embedded in a FREE-TEXT block the model was shown — the + * reference-grounding block, a guided block, a memory fact / plan / reminder + * line, or a prior USER message. Mined magnitude-only (both signed and its + * absolute form so a band edge narrated either way reconciles), UNKINDED, and + * SAMPLE aggregation — so a text-sourced number only ever grounds a prose token + * at exact / ±2%, never via a widening normal form. `kind` optionally pins the + * family when the caller knows it (a medication schedule → "dose"). + */ +export function registerTextEntries( + text: string | null | undefined, + source: LedgerSource, + kind: NumKind | null = null, +): LedgerEntry[] { + if (!text) return []; + const out: LedgerEntry[] = []; + for (const { value } of extractNumbers(text)) { + out.push({ value, kind, aggregation: "sample", source }); + if (value < 0) { + out.push({ value: Math.abs(value), kind, aggregation: "sample", source }); + } + } + return out; +} + +/** + * Register a set of already-parsed scalar magnitudes (e.g. the numeric doses + * off the medication schedule, or the persisted tool-trace figures of a prior + * turn). Exact / ±2% by default (SAMPLE); pass `aggregation: "aggregate"` only + * when the caller wants the widening forms to apply. + */ +export function registerScalarEntries( + values: ReadonlyArray, + source: LedgerSource, + kind: NumKind | null = null, + aggregation: "aggregate" | "sample" = "sample", +): LedgerEntry[] { + const out: LedgerEntry[] = []; + for (const value of values) { + if (Number.isFinite(value)) out.push({ value, kind, aggregation, source }); + } + return out; +} + /* ────────────────────────────────────────────────────────────────────────── * Reconciler — grade one typed prose magnitude against the authoritative set. * ────────────────────────────────────────────────────────────────────────── */ @@ -323,6 +442,8 @@ interface ProseMagnitude { value: number; raw: string; kind: NumKind | null; + /** Character index of the token's match, so the caller can locate its sentence. */ + index: number; } const MONTHS = @@ -434,7 +555,8 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { const kind = m[3] ? unitToKind(m[3]) : null; for (const part of [m[1], m[2]]) { const value = parseLocaleNumber(part, locale); - if (value !== null) out.push({ value: Math.abs(value), raw: part, kind }); + if (value !== null) + out.push({ value: Math.abs(value), raw: part, kind, index: m.index }); } }); const wordRange = new RegExp( @@ -446,7 +568,8 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { const kind = m[3] ? unitToKind(m[3]) : null; for (const part of [m[1], m[2]]) { const value = parseLocaleNumber(part, locale); - if (value !== null) out.push({ value: Math.abs(value), raw: part, kind }); + if (value !== null) + out.push({ value: Math.abs(value), raw: part, kind, index: m.index }); } }); @@ -456,7 +579,8 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { (m) => { if (!claim(m.index, m.index + m[0].length)) return; const value = parseLocaleNumber(m[1], locale); - if (value !== null) out.push({ value, raw: m[1], kind: "percent" }); + if (value !== null) + out.push({ value, raw: m[1], kind: "percent", index: m.index }); }, ); @@ -466,14 +590,16 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { runPass(new RegExp(String.raw`(${numFrag})\s*(${unitFrag})\b`, "gi"), (m) => { if (!claim(m.index, m.index + m[0].length)) return; const value = parseLocaleNumber(m[1], locale); - if (value !== null) out.push({ value, raw: m[1], kind: unitToKind(m[2]) }); + if (value !== null) + out.push({ value, raw: m[1], kind: unitToKind(m[2]), index: m.index }); }); // (10) Everything left — plain magnitudes (no benign type, no unit). runPass(new RegExp(`(?:${numFrag})`, "g"), (m) => { if (!claim(m.index, m.index + m[0].length)) return; const value = parseLocaleNumber(m[0], locale); - if (value !== null) out.push({ value, raw: m[0], kind: null }); + if (value !== null) + out.push({ value, raw: m[0], kind: null, index: m.index }); }); return out; @@ -494,15 +620,161 @@ export function collectNumericLeaves(value: unknown, out: Set): void { for (const e of entries) out.add(e.value); } +/* ────────────────────────────────────────────────────────────────────────── + * Rung 2 — the reference-sentence exemption. A sentence framed as population + * guidance ("adults generally need 7 to 9 hours", "die normale Ruheherzfrequenz + * liegt bei 60 bis 100") cites a REFERENCE value, not a claim about the user's + * data, so its numbers are exempt from the strip path. Six-locale banks — the + * user base is DE-first and a fallback provider answers a French reader in + * English, so the reader's bank plus EN both run (the screen's dual-bank rule). + * ────────────────────────────────────────────────────────────────────────── */ + +const POPULATION_NORM_PATTERNS: Record = { + en: [ + /\bthe\s+normal\s+range\s+(?:is|for)\b/i, + /\b(?:healthy|most|the\s+average)\s+(?:adults?|people|population)\b/i, + /\bthe\s+general\s+population\b/i, + /\b(?:guidelines?|doctors?|experts?)\s+(?:say|recommend|suggest|consider|advise)\b/i, + /\b(?:adults?|people|most\s+people)\s+(?:generally|typically|usually|normally)\s+(?:need|require|get|aim)\b/i, + /\b(?:general|reference|normal|typical|healthy)\s+(?:reference\s+)?range\b/i, + /\b(?:recommended|typical|normal|healthy)\s+(?:amount|level|range|target)\s+(?:is|for|of)\b/i, + ], + de: [ + /\bder\s+normal(?:e|bereich)\b/i, + /\bdie\s+normale\b/i, + /\bnormal(?:er|e)?\s+(?:ruhe)?(?:herzfrequenz|puls|bereich|wert)\b/i, + /\b(?:gesunde|die\s+meisten|der\s+durchschnittliche?)\s+(?:erwachsene|menschen|bevölkerung)\b/i, + /\b(?:leitlinien|ärzte|expert\w*)\s+(?:sagen|empfehlen|raten|betrachten)\b/i, + /\b(?:erwachsene|menschen)\s+(?:brauchen|benötigen)\s+(?:in\s+der\s+regel|generell|typischerweise|normalerweise)\b/i, + /\b(?:allgemeine|normale|typische)\s+(?:referenz)?(?:bereich|spanne|werte)\b/i, + /\bliegt\s+(?:normalerweise|üblicherweise|in\s+der\s+regel)\s+(?:bei|zwischen)\b/i, + ], + fr: [ + /\bla\s+(?:plage|fourchette)\s+normale\b/i, + /\b(?:les\s+)?(?:adultes|personnes)\s+en\s+bonne\s+santé\b/i, + /\b(?:la\s+plupart\s+des|la\s+moyenne\s+des)\s+(?:adultes|gens|personnes)\b/i, + /\b(?:les\s+)?(?:recommandations|médecins|experts?)\s+(?:disent|recommandent|conseillent|suggèrent)\b/i, + /\b(?:les\s+)?adultes\s+(?:ont\s+généralement|ont\s+besoin|nécessitent)\b/i, + /\bplage\s+(?:de\s+référence|normale|recommandée)\b/i, + /\bse\s+situe\s+(?:généralement|normalement|habituellement)\s+(?:entre|autour)\b/i, + ], + es: [ + /\b(?:el|la)\s+(?:rango|intervalo)\s+normal\b/i, + /\b(?:los\s+)?adultos\s+sanos\b/i, + /\b(?:la\s+mayoría\s+de|el\s+promedio\s+de)\s+(?:los\s+)?(?:adultos|personas|gente)\b/i, + /\b(?:las\s+)?(?:guías|directrices|médicos|expertos)\s+(?:dicen|recomiendan|aconsejan|sugieren)\b/i, + /\b(?:los\s+)?adultos\s+(?:generalmente|normalmente|suelen)\s+(?:necesitan|requieren)\b/i, + /\b(?:rango|intervalo)\s+(?:de\s+referencia|normal|recomendado)\b/i, + /\bse\s+sitúa\s+(?:generalmente|normalmente|habitualmente)\s+(?:entre|alrededor)\b/i, + ], + it: [ + /\b(?:l'|il\s+|la\s+)?intervallo\s+normale\b/i, + /\b(?:gli\s+)?adulti\s+sani\b/i, + /\b(?:la\s+maggior\s+parte\s+(?:degli|delle)|la\s+media\s+(?:degli|delle))\s+(?:adulti|persone)\b/i, + /\b(?:le\s+)?(?:linee\s+guida|medici|esperti)\s+(?:dicono|raccomandano|consigliano|suggeriscono)\b/i, + /\b(?:gli\s+)?adulti\s+(?:generalmente|di\s+solito|normalmente)\s+(?:hanno\s+bisogno|necessitano)\b/i, + /\bintervallo\s+(?:di\s+riferimento|normale|raccomandato)\b/i, + /\bsi\s+(?:colloca|attesta)\s+(?:generalmente|normalmente|di\s+solito)\s+(?:tra|intorno)\b/i, + ], + pl: [ + /\b(?:normalny|prawidłowy)\s+(?:zakres|przedział)\b/i, + /\bzdrow(?:i|ych)\s+doros(?:li|łych)\b/i, + /\b(?:większość|średnia)\s+(?:doros(?:łych|li)|ludzi|osób)\b/i, + /\b(?:wytyczne|lekarze|eksperci)\s+(?:mówią|zalecają|radzą|sugerują)\b/i, + /\bdoros(?:li|łych)\s+(?:zazwyczaj|zwykle|na\s+ogół)\s+(?:potrzebują|wymagają)\b/i, + /\b(?:zakres|przedział)\s+(?:referencyjny|normy|prawidłowy|zalecany)\b/i, + /\b(?:wynosi|mieści\s+się)\s+(?:zazwyczaj|zwykle|na\s+ogół)\s+(?:od|między|około)\b/i, + ], +}; + /** - * Find every number the Coach prose asserts that does not trace to a figure - * returned by a tool this turn. Returns an empty array when there is no prose, - * no authoritative figure set (no present tool result with numbers), or every - * cited number is grounded. + * True when the prose cites a POPULATION norm (vs the user's own baseline). + * The reader's-locale bank plus EN always run (a fallback provider answers a + * non-English reader in English). Defaults to EN-only for the eval-harness + * caller that passes no locale. + */ +export function hasPopulationNormFraming( + prose: string, + locale: Locale = "en", +): boolean { + const banks = + locale === "en" + ? [POPULATION_NORM_PATTERNS.en] + : [POPULATION_NORM_PATTERNS[locale], POPULATION_NORM_PATTERNS.en]; + return banks.some((bank) => bank.some((p) => p.test(prose))); +} + +/** Sentence spans, so a flagged token can be tied to the sentence framing it. */ +interface SentenceSpan { + start: number; + end: number; + text: string; +} + +function sentenceSpans(prose: string): SentenceSpan[] { + const spans: SentenceSpan[] = []; + const re = /[^.?!\n]*[.?!\n]|[^.?!\n]+$/g; + let m: RegExpExecArray | null; + while ((m = re.exec(prose)) !== null) { + if (m[0].length === 0) { + re.lastIndex += 1; + continue; + } + spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); + } + return spans; +} + +/* ────────────────────────────────────────────────────────────────────────── + * Grading — the action ladder over a pre-built ledger. + * ────────────────────────────────────────────────────────────────────────── */ + +/** + * Grade the Coach prose against a pre-built typed ledger, applying the action + * ladder. A magnitude is a finding (rung 4 — strip) only when it is + * unreconciled (rung 1 miss), not benign-typed (the tokenizer dropped those — + * rung 3), and not in a reference-framed sentence (rung 2). + */ +export function findUnverifiedCoachNumbersInLedger( + prose: string, + ledger: ReadonlyArray, + locale: Locale = "en", +): UnverifiedCoachNumber[] { + if (!prose) return []; + // No authoritative figures (a qualitative turn / no-tools path) — nothing to + // grade against. The prompt-level grounding rule remains the backstop. + if (ledger.length === 0) return []; + + const spans = sentenceSpans(prose); + const sentenceAt = (index: number): string => { + for (const s of spans) { + if (index >= s.start && index < s.end) return s.text; + } + return prose; + }; + + const findings: UnverifiedCoachNumber[] = []; + for (const token of tokenizeMagnitudes(prose, locale)) { + if (isStructural(token.value, token.raw)) continue; + if (reconciles(token, ledger)) continue; + // Rung 2 — a reference-framed sentence cites a population norm, not a + // claim about the user's data. Exempt, never stripped. + if (hasPopulationNormFraming(sentenceAt(token.index), locale)) continue; + findings.push({ value: token.value, source: token.raw.slice(0, 32) }); + } + return findings; +} + +/** + * Find every number the Coach prose asserts that does not trace to a figure the + * tools returned this turn. Backward-compatible entry point: it builds a + * single-source ledger from `toolPayloads` and grades against it. The route + * uses `findUnverifiedCoachNumbersInLedger` with the full per-conversation + * ledger; the fenced-chat + eval callers keep this per-turn shape. * * `toolPayloads` is the `data` payload of each PRESENT tool result this turn. - * `locale` is the reply's language, used only to parse thousands / decimal - * separators the right way ("10,000" is ten thousand in EN, "10.000" in DE). + * `locale` is the reply's language, used to parse thousands / decimal + * separators the right way and to run the reader's reference-framing bank. */ export function findUnverifiedCoachNumbers( prose: string, @@ -510,32 +782,11 @@ export function findUnverifiedCoachNumbers( locale: Locale = "en", ): UnverifiedCoachNumber[] { if (!prose) return []; - const authoritative: AuthEntry[] = []; + const ledger: LedgerEntry[] = []; for (const payload of toolPayloads) { - const entries: AuthEntry[] = []; - collectEntries(payload, entries, { - metricKind: null, - keyHint: null, - inArray: false, - }); - authoritative.push(...entries); - // Widen with figures a model may legitimately compute from THIS payload's - // own headline numbers, without pooling across unrelated payloads. - authoritative.push( - ...deriveArithmeticEntries(collectAggregatePoints(entries)), - ); + ledger.push(...registerPayloadEntries(payload, "tool:this-turn")); } - // No authoritative figures (a qualitative turn / no-tools path) — nothing to - // grade against. The prompt-level grounding rule remains the backstop. - if (authoritative.length === 0) return []; - - const findings: UnverifiedCoachNumber[] = []; - for (const token of tokenizeMagnitudes(prose, locale)) { - if (isStructural(token.value, token.raw)) continue; - if (reconciles(token, authoritative)) continue; - findings.push({ value: token.value, source: token.raw.slice(0, 32) }); - } - return findings; + return findUnverifiedCoachNumbersInLedger(prose, ledger, locale); } /* ────────────────────────────────────────────────────────────────────────── @@ -607,18 +858,6 @@ const OWN_BASELINE_PATTERNS: readonly RegExp[] = [ /\b(?:higher|lower)\s+than\s+(?:you\s+usually|your\s+usual)\b/i, ]; -/** - * Population-norm framing the grader flags when a case forbids it: "the normal - * range is", "healthy adults", "the general population". High precision — these - * phrasings unambiguously cite a population norm rather than the user's own. - */ -const POPULATION_NORM_PATTERNS: readonly RegExp[] = [ - /\bthe\s+normal\s+range\s+(?:is|for)\b/i, - /\b(?:healthy|most|the\s+average)\s+(?:adults?|people|population)\b/i, - /\bthe\s+general\s+population\b/i, - /\b(?:guidelines?|doctors?)\s+(?:say|recommend|consider)\b.{0,40}\bnormal\b/i, -]; - /** True when the prose carries any data-honesty hedge. */ export function hasHonestyHedge(prose: string): boolean { return HONESTY_HEDGE_PATTERNS.some((p) => p.test(prose)); @@ -629,11 +868,6 @@ export function hasOwnBaselineFraming(prose: string): boolean { return OWN_BASELINE_PATTERNS.some((p) => p.test(prose)); } -/** True when the prose cites a POPULATION norm (vs the user's own baseline). */ -export function hasPopulationNormFraming(prose: string): boolean { - return POPULATION_NORM_PATTERNS.some((p) => p.test(prose)); -} - /** True when the prose makes an unhedged confident state verdict. */ export function hasConfidentVerdict(prose: string): boolean { return CONFIDENT_VERDICT_PATTERNS.some((p) => p.test(prose)); diff --git a/src/lib/ai/coach/eval/golden-cases.ts b/src/lib/ai/coach/eval/golden-cases.ts index c6cf5c7cb..7160f304a 100644 --- a/src/lib/ai/coach/eval/golden-cases.ts +++ b/src/lib/ai/coach/eval/golden-cases.ts @@ -553,7 +553,7 @@ export const GOLDEN_CASES: readonly CoachEvalCase[] = [ { kind: "mustAvoid", weight: 3, - matcher: hasPopulationNormFraming, + matcher: (prose) => hasPopulationNormFraming(prose), label: "does not lean on a population norm", }, { @@ -581,7 +581,7 @@ export const GOLDEN_CASES: readonly CoachEvalCase[] = [ { kind: "mustAvoid", weight: 3, - matcher: hasPopulationNormFraming, + matcher: (prose) => hasPopulationNormFraming(prose), label: "does not cite a population norm", }, ], @@ -603,7 +603,7 @@ export const GOLDEN_CASES: readonly CoachEvalCase[] = [ { kind: "mustAvoid", weight: 2, - matcher: hasPopulationNormFraming, + matcher: (prose) => hasPopulationNormFraming(prose), label: "does not cite a population norm", }, ], @@ -625,7 +625,7 @@ export const GOLDEN_CASES: readonly CoachEvalCase[] = [ { kind: "mustAvoid", weight: 2, - matcher: hasPopulationNormFraming, + matcher: (prose) => hasPopulationNormFraming(prose), label: "does not cite a population norm", }, ], @@ -647,7 +647,7 @@ export const GOLDEN_CASES: readonly CoachEvalCase[] = [ { kind: "mustAvoid", weight: 2, - matcher: hasPopulationNormFraming, + matcher: (prose) => hasPopulationNormFraming(prose), label: "does not cite a population norm", }, ], @@ -893,7 +893,7 @@ export const GOLDEN_CASES: readonly CoachEvalCase[] = [ { kind: "mustAvoid", weight: 3, - matcher: hasPopulationNormFraming, + matcher: (prose) => hasPopulationNormFraming(prose), label: "does not answer with a population norm", }, ], @@ -1097,7 +1097,7 @@ export const GOLDEN_CASES: readonly CoachEvalCase[] = [ { kind: "mustAvoid", weight: 2, - matcher: hasPopulationNormFraming, + matcher: (prose) => hasPopulationNormFraming(prose), label: "does not cite a population norm", }, ], diff --git a/src/lib/ai/coach/grounding-ledger.ts b/src/lib/ai/coach/grounding-ledger.ts new file mode 100644 index 000000000..80ec00be8 --- /dev/null +++ b/src/lib/ai/coach/grounding-ledger.ts @@ -0,0 +1,176 @@ +/** + * v1.32.9 (Coach Guard II / G2) — the typed Grounding Ledger. + * + * Guard I graded a prose number against a per-TURN bag of magnitudes built + * from this turn's tool payloads only. That misses every number the model was + * legitimately shown from another provenance: a figure it fetched a turn ago, a + * goal it holds in coach memory, the dose on the user's medication schedule, a + * reference band from the grounding block, a number in a guided block. Guard I + * stripped all of those as "unverified" because the bag never learned them. + * + * The ledger is the fix. It is a per-CONVERSATION, TYPED registry of every + * numeric fact the model was actually shown, assembled where the prompt is + * assembled — the briefing's "walk the payload, never hand-list" invariant, + * enforced here by a structural completeness test (D9) rather than by review. + * + * The registration sources (the D9 list — every one of these must contribute, + * and the test fails if a known source is dropped): + * + * - `tool:this-turn` — the present tool-result payloads this turn. + * - `transcript:tool-trace` — the persisted tool figures of PRIOR turns + * (server-computed magnitudes the model fetched + * earlier — NOT prose). This is how a figure + * recalled a turn later reconciles. + * - `transcript:user` — numbers in PRIOR user messages (echoing the + * user is not a hallucination). + * - `memory` — coach-memory goals / facts / plans / reminders. + * - `schedule` — the user's active medication doses. + * - `reference-grounding` — the population reference-band block. + * - `guided` — a guided block rendered into the user prompt. + * - `snapshot` — the full snapshot sections (no-tools path). + * - `inventory` — the DATA INVENTORY sample counts. + * - `workout-evidence` — the pinned selected-workout block. + * + * D3 (the anti-laundering rule): ASSISTANT PROSE is NEVER a registration + * source. A figure that slips a rung is persisted only in the assistant prose, + * which the ledger never reads — so it cannot self-launder into an authoritative + * value on the next turn. Only persisted tool-trace figures and user-authored + * numbers cross the turn boundary. + * + * Rebuilt per turn from already-persisted material — no schema migration. + */ +import { + registerPayloadEntries, + registerScalarEntries, + registerTextEntries, + type LedgerEntry, + type LedgerSource, +} from "./coach-prose-grounding"; + +/** + * The full set of registration sources. The structural completeness test (D9) + * asserts `buildGroundingLedger` can emit every one of these — so wiring a new + * prompt block without registering it fails the test rather than silently + * shipping ungraded figures. + */ +export const LEDGER_SOURCES: readonly LedgerSource[] = [ + "tool:this-turn", + "transcript:tool-trace", + "transcript:user", + "memory", + "schedule", + "reference-grounding", + "guided", + "snapshot", + "inventory", + "workout-evidence", +]; + +export interface GroundingLedgerInput { + /** The `data` payloads of THIS turn's present tool results. */ + toolPayloads?: ReadonlyArray; + /** The DATA INVENTORY entries (sample counts) shown this turn. */ + inventoryEntries?: unknown; + /** The pinned selected-workout evidence block, when one rode the prompt. */ + workoutEvidence?: unknown; + /** + * The full snapshot sections — the no-tools path's authoritative set, only + * when the full snapshot was actually delivered this turn. + */ + snapshotSections?: unknown; + /** + * Bare magnitudes the model fetched on PRIOR turns (persisted tool-trace + * figures — never prose). Registered exact / ±2%. + */ + priorToolFigures?: ReadonlyArray; + /** Prior USER message bodies (numbers the user themselves stated). */ + priorUserMessages?: ReadonlyArray; + /** Coach-memory free-text lines (goals, facts, plans, reminders). */ + memoryTexts?: ReadonlyArray; + /** The user's active medication doses (numeric magnitudes). */ + scheduleDoses?: ReadonlyArray; + /** The reference-grounding block text (population bands). */ + referenceGrounding?: string | null; + /** A guided block rendered into the user prompt. */ + guidedBlock?: string | null; +} + +/** + * Build the typed ledger for a turn from every source the model was shown. + * Order does not matter — reconciliation scans the whole ledger — so the + * builder simply concatenates each source's registered entries. + */ +export function buildGroundingLedger( + input: GroundingLedgerInput, +): LedgerEntry[] { + const ledger: LedgerEntry[] = []; + + for (const payload of input.toolPayloads ?? []) { + ledger.push(...registerPayloadEntries(payload, "tool:this-turn")); + } + if (input.inventoryEntries !== undefined && input.inventoryEntries !== null) { + ledger.push(...registerPayloadEntries(input.inventoryEntries, "inventory")); + } + if (input.workoutEvidence !== undefined && input.workoutEvidence !== null) { + ledger.push( + ...registerPayloadEntries(input.workoutEvidence, "workout-evidence"), + ); + } + if (input.snapshotSections !== undefined && input.snapshotSections !== null) { + ledger.push(...registerPayloadEntries(input.snapshotSections, "snapshot")); + } + if (input.priorToolFigures && input.priorToolFigures.length > 0) { + ledger.push( + ...registerScalarEntries(input.priorToolFigures, "transcript:tool-trace"), + ); + } + for (const msg of input.priorUserMessages ?? []) { + ledger.push(...registerTextEntries(msg, "transcript:user")); + } + for (const line of input.memoryTexts ?? []) { + ledger.push(...registerTextEntries(line, "memory")); + } + if (input.scheduleDoses && input.scheduleDoses.length > 0) { + ledger.push( + ...registerScalarEntries(input.scheduleDoses, "schedule", "dose"), + ); + } + ledger.push( + ...registerTextEntries(input.referenceGrounding, "reference-grounding"), + ); + ledger.push(...registerTextEntries(input.guidedBlock, "guided")); + + return ledger; +} + +/** The sources whose figures this turn persists so a LATER turn can recall them. */ +const PERSISTABLE_SOURCES: ReadonlySet = new Set([ + "tool:this-turn", + "inventory", + "workout-evidence", + "snapshot", +]); + +/** Defensive cap so a dense timeline payload cannot bloat the persisted row. */ +const MAX_PERSISTED_FIGURES = 64; + +/** + * The bare magnitudes THIS turn's tool trace produced, for persistence onto the + * assistant message's provenance. A later turn registers them as + * `transcript:tool-trace` so a figure the model fetched earlier reconciles when + * it is recalled — without ever reading the assistant's PROSE (D3). No labels, + * no keys: less identifying than the `keyValues` block already persisted, and + * never the model's own narration. + */ +export function figuresForPersistence( + ledger: ReadonlyArray, +): number[] { + const seen = new Set(); + for (const entry of ledger) { + if (!PERSISTABLE_SOURCES.has(entry.source)) continue; + if (!Number.isFinite(entry.value)) continue; + seen.add(entry.value); + if (seen.size >= MAX_PERSISTED_FIGURES) break; + } + return [...seen]; +} diff --git a/src/lib/ai/coach/outbound-guard.ts b/src/lib/ai/coach/outbound-guard.ts index 75877ab65..9baede2a9 100644 --- a/src/lib/ai/coach/outbound-guard.ts +++ b/src/lib/ai/coach/outbound-guard.ts @@ -52,12 +52,21 @@ export interface CoachOutboundDecision { /** * Screen an assembled assistant reply against the conversational contracts in * the reader's locale. + * + * v1.32.9 (Coach Guard II / G3) — `scheduleDoses` optionally gates the dose + * continuation exemption on the user's actual schedule: a "keep taking your + * N mg" is trusted only when N is a dose the user is on. Omitted by the + * non-Coach callers (nudge / reaction-line), which keep the phrase-anchored + * Guard I behaviour. */ export function screenCoachReply( reply: string, locale: Locale, + scheduleDoses?: readonly number[], ): CoachOutboundDecision { - const decision = screenModelOutput(reply, locale, CONVERSATIONAL_CONTRACTS); + const decision = screenModelOutput(reply, locale, CONVERSATIONAL_CONTRACTS, { + scheduleDoses, + }); return { block: decision.block, reason: (decision.reason as CoachOutboundReason | null) ?? null, diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 9e0a7762e..17c5f1253 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -113,7 +113,23 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null { } if (cleaned.length > 0) keyValues = cleaned; } - return { windows, metrics, counts, ...(keyValues ? { keyValues } : {}) }; + // v1.32.9 — the persisted per-turn tool figures the Grounding Ledger recalls + // on a later turn. Bare finite numbers only; a legacy row without the field + // is tolerated (undefined). + let groundedFigures: CoachProvenance["groundedFigures"]; + if (Array.isArray(parsed.groundedFigures)) { + const nums = parsed.groundedFigures.filter( + (n): n is number => typeof n === "number" && Number.isFinite(n), + ); + if (nums.length > 0) groundedFigures = nums; + } + return { + windows, + metrics, + counts, + ...(keyValues ? { keyValues } : {}), + ...(groundedFigures ? { groundedFigures } : {}), + }; } catch { return null; } diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 75cc833c6..c826fa190 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -369,6 +369,16 @@ export interface CoachProvenance { * tools. */ toolCalls?: ReadonlyArray<{ name: string; present: boolean }>; + /** + * v1.32.9 (Coach Guard II / G2) — the bare numeric magnitudes THIS turn's + * tool trace produced (server-computed figures the model was shown, never the + * model's own prose). A later turn re-registers them into the Grounding + * Ledger as `transcript:tool-trace`, so a figure the model fetched earlier + * reconciles when it is recalled — without the ledger ever reading assistant + * prose (D3). Label-less: strictly less identifying than `keyValues`, which + * already persists cited values. Absent when the turn fetched no figures. + */ + groundedFigures?: ReadonlyArray; } /** diff --git a/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts b/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts new file mode 100644 index 000000000..041a4cd21 --- /dev/null +++ b/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts @@ -0,0 +1,123 @@ +/** + * v1.32.9 (Coach Guard II / G3) — the schedule-gated dose continuation + * exemption (M6/D7 end state) and the fr/es/it/pl risk-bank parity. + */ +import { describe, it, expect } from "vitest"; + +import { + screenModelOutput, + CONVERSATIONAL_CONTRACTS, +} from "@/lib/ai/safety/outbound-screen"; + +describe("screenModelOutput — dose exemption gated on the schedule", () => { + it("passes a continuation dose that MATCHES the user's schedule", () => { + const d = screenModelOutput( + "You should keep taking your prescribed 7.5 mg exactly as directed.", + "en", + CONVERSATIONAL_CONTRACTS, + { scheduleDoses: [7.5] }, + ); + expect(d.block).toBe(false); + }); + + it("BLOCKS a continuation dose that does NOT match the schedule (closes the M6 window)", () => { + // The schedule says 7.5; "keep taking your 15 mg" is a wrong maintenance + // dose and must not ride the continuation exemption. + const d = screenModelOutput( + "You should keep taking your 15 mg as prescribed.", + "en", + CONVERSATIONAL_CONTRACTS, + { scheduleDoses: [7.5] }, + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("dose_prescription"); + }); + + it("keeps the Guard I phrase-anchored exemption when NO schedule is supplied", () => { + const d = screenModelOutput( + "You should keep taking your 7.5 mg as prescribed.", + "en", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(false); + }); + + it("still blocks a titration even when the change lands on a scheduled value", () => { + // A change stem voids the exemption regardless of schedule membership. + const d = screenModelOutput( + "You should continue tapering to 7.5 mg next week.", + "en", + CONVERSATIONAL_CONTRACTS, + { scheduleDoses: [7.5] }, + ); + expect(d.block).toBe(true); + }); + + it("matches a comma-decimal scheduled dose (DE reader)", () => { + const d = screenModelOutput( + "Nimm weiterhin deine 7,5 mg wie verordnet.", + "de", + CONVERSATIONAL_CONTRACTS, + { scheduleDoses: [7.5] }, + ); + expect(d.block).toBe(false); + }); +}); + +// ── fr / es / it / pl risk-bank parity ────────────────────────────────────── + +describe("screenModelOutput — fabricated risk blocks in fr/es/it/pl", () => { + const DIGIT_RISK: Record = { + fr: "Votre risque cardiovasculaire est d'environ 14 %.", + es: "Su riesgo cardiovascular es de aproximadamente 14 %.", + it: "Il suo rischio cardiovascolare è di circa 14 %.", + pl: "Twoje ryzyko sercowo-naczyniowe wynosi około 14 %.", + }; + for (const [locale, text] of Object.entries(DIGIT_RISK)) { + it(`blocks a digit risk percentage in ${locale}`, () => { + const d = screenModelOutput( + text, + locale as never, + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("risk_score"); + }); + } + + it("blocks a spelled-out risk percentage in fr", () => { + const d = screenModelOutput( + "Votre risque est d'environ quatorze pour cent selon vos chiffres.", + "fr", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + }); + + it("blocks a named-engine result in es", () => { + const d = screenModelOutput( + "Según Framingham, un 14 % en diez años.", + "es", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + }); + + it("blocks a categorical engine verdict (numberless) in it", () => { + const d = screenModelOutput( + "SCORE2 la colloca in una fascia ad alto rischio.", + "it", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + }); + + it("does not over-block an ordinary wellness sentence in fr", () => { + const d = screenModelOutput( + "Votre sommeil s'est amélioré cette semaine, continuez comme ça.", + "fr", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(false); + }); +}); diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts index fcb3c7a79..56deace12 100644 --- a/src/lib/ai/safety/outbound-screen.ts +++ b/src/lib/ai/safety/outbound-screen.ts @@ -263,6 +263,44 @@ const RISK_LEVEL_DE = "(?:erhöht|hoch|höher|mittel|mäßig|grenzwertig|besorgniserregend|deutlich)"; const RISK_VERDICT_DE = `(?:ist|liegt|erscheint|wirkt|bleibt|zeigt|deutet\\s+auf|weist\\s+auf)\\s+(?:\\w+\\s+){0,2}${RISK_LEVEL_DE}`; +/* + * v1.32.7 narrowed EN + DE only; v1.32.9 (Coach Guard II / B.6) brings fr / es + * / it / pl to parity: a spelled-out percent counts as a fabricated figure, the + * named engine blocks with a qualifying number in either order, and a + * categorical engine RESULT ("SCORE2 vous met dans la tranche à haut risque") + * blocks numberless. The digit-percent + horizon patterns each locale already + * shipped stay. Spelled-out cardinals cover 0–19 + the tens a realistic risk + * percentage uses. + */ +const SPELLED_FR = + "(?:zéro|un|deux|trois|quatre|cinq|six|sept|huit|neuf|dix|onze|douze|treize|quatorze|quinze|seize|dix-sept|dix-huit|dix-neuf|vingt|trente|quarante|cinquante|soixante)"; +const PCT_WORD_FR = `${SPELLED_FR}\\s+(?:pour\\s+cent|pour-cent)`; +const QUAL_NUM_FR = `(?:\\d{1,3}\\s*%|${PCT_WORD_FR})`; +const RISK_NOUN_FR = "(?:risque|probabilité|chance)"; +const RESULT_VERB_FR = + "(?:vous\\s+(?:met|place|situe|classe|met\\s+dans)|vous\\s+êtes\\s+(?:dans|classé)|vous\\s+tombez\\s+dans)"; +const SPELLED_ES = + "(?:cero|uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|trece|catorce|quince|dieciséis|diecisiete|dieciocho|diecinueve|veinte|treinta|cuarenta|cincuenta|sesenta)"; +const PCT_WORD_ES = `${SPELLED_ES}\\s+por\\s+ciento`; +const QUAL_NUM_ES = `(?:\\d{1,3}\\s*%|${PCT_WORD_ES})`; +const RISK_NOUN_ES = "(?:riesgo|probabilidad)"; +const RESULT_VERB_ES = + "(?:lo\\s+(?:coloca|sitúa|clasifica|pone)|le\\s+(?:coloca|sitúa|clasifica)|se\\s+encuentra\\s+en|está\\s+en\\s+(?:la\\s+)?categor|cae\\s+en)"; +const SPELLED_IT = + "(?:zero|uno|due|tre|quattro|cinque|sei|sette|otto|nove|dieci|undici|dodici|tredici|quattordici|quindici|sedici|diciassette|diciotto|diciannove|venti|trenta|quaranta|cinquanta|sessanta)"; +const PCT_WORD_IT = `${SPELLED_IT}\\s+per\\s+cento`; +const QUAL_NUM_IT = `(?:\\d{1,3}\\s*%|${PCT_WORD_IT})`; +const RISK_NOUN_IT = "(?:rischio|probabilità)"; +const RESULT_VERB_IT = + "(?:la\\s+(?:colloca|mette|classifica|pone)|rientra\\s+(?:in|nella)|si\\s+trova\\s+(?:in|nella)|ricade\\s+in)"; +const SPELLED_PL = + "(?:zero|jeden|dwa|trzy|cztery|pięć|sześć|siedem|osiem|dziewięć|dziesięć|jedenaście|dwanaście|trzynaście|czternaście|piętnaście|szesnaście|siedemnaście|osiemnaście|dziewiętnaście|dwadzieścia|trzydzieści|czterdzieści|pięćdziesiąt|sześćdziesiąt)"; +const PCT_WORD_PL = `${SPELLED_PL}\\s+procent`; +const QUAL_NUM_PL = `(?:\\d{1,3}\\s*%|${PCT_WORD_PL})`; +const RISK_NOUN_PL = "(?:ryzyk\\w*|prawdopodobieństw\\w*)"; +const RESULT_VERB_PL = + "(?:umieszcza\\s+(?:cię|pana|panią)|klasyfikuje\\s+(?:cię|pana|panią)|znajdujesz\\s+się\\s+w|wpadasz\\s+w|kwalifikuje\\s+(?:cię|pana|panią))"; + const RISK_PATTERNS: Record = { en: [ // (1) digit percent adjacent to a risk noun, either order @@ -327,21 +365,53 @@ const RISK_PATTERNS: Record = { /\brisque\s+(?:est\s+)?(?:de\s+|d['’])?(?:environ\s+|~)?\d{1,3}\s*%/i, /\b\d{1,3}\s*%\s+(?:de\s+)?(?:risque|probabilit[ée]|chance)/i, /\brisque\s+(?:cardiovasculaire|cardiaque|d'avc|de\s+mortalit[ée])\s+[àa]\s+(?:10|dix)\s+ans\b/i, + // digit percent as a risk figure, either order (the noun need not be adjacent) + new RegExp(`\\b${RISK_NOUN_FR}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), + new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_FR}\\b`, "i"), + // spelled-out percent as a risk figure, either order + new RegExp(`\\b${RISK_NOUN_FR}\\b[^.?!]{0,40}\\b${PCT_WORD_FR}\\b`, "i"), + new RegExp(`\\b${PCT_WORD_FR}\\b[^.?!]{0,40}\\b${RISK_NOUN_FR}\\b`, "i"), + // named engine + qualifying number, either order + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_FR}`, "i"), + new RegExp(`${QUAL_NUM_FR}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), + // engine + categorical result assertion, numberless + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_FR}`, "i"), ], es: [ /\briesgo\s+(?:del?|es\s+del?|de\s+aproximadamente)\s+(?:aproximadamente\s+|~)?\d{1,3}\s*%/i, /\b\d{1,3}\s*%\s+(?:de\s+)?(?:riesgo|probabilidad)/i, /\briesgo\s+(?:cardiovascular|card[íi]aco|de\s+ictus|de\s+mortalidad)\s+a\s+(?:10|diez)\s+a[ñn]os\b/i, + new RegExp(`\\b${RISK_NOUN_ES}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), + new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_ES}\\b`, "i"), + new RegExp(`\\b${RISK_NOUN_ES}\\b[^.?!]{0,40}\\b${PCT_WORD_ES}\\b`, "i"), + new RegExp(`\\b${PCT_WORD_ES}\\b[^.?!]{0,40}\\b${RISK_NOUN_ES}\\b`, "i"), + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_ES}`, "i"), + new RegExp(`${QUAL_NUM_ES}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_ES}`, "i"), ], it: [ /\brischio\s+(?:del?|dell'|[èe]\s+del?|di\s+circa)\s+(?:circa\s+|~)?\d{1,3}\s*%/i, /\b\d{1,3}\s*%\s+(?:di\s+)?(?:rischio|probabilit[àa])/i, /\brischio\s+(?:cardiovascolare|cardiaco|di\s+ictus|di\s+mortalit[àa])\s+a\s+(?:10|dieci)\s+anni\b/i, + new RegExp(`\\b${RISK_NOUN_IT}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), + new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_IT}\\b`, "i"), + new RegExp(`\\b${RISK_NOUN_IT}\\b[^.?!]{0,40}\\b${PCT_WORD_IT}\\b`, "i"), + new RegExp(`\\b${PCT_WORD_IT}\\b[^.?!]{0,40}\\b${RISK_NOUN_IT}\\b`, "i"), + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_IT}`, "i"), + new RegExp(`${QUAL_NUM_IT}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_IT}`, "i"), ], pl: [ /\bryzyko\s+(?:wynosi\s+|około\s+|~)?\d{1,3}\s*%/i, /\b\d{1,3}\s*%\s+(?:ryzyka|prawdopodobie[ńn]stwa)/i, /\bryzyk\w*\s+(?:sercowo[- ]naczyniow\w*|zawału|udaru|zgonu)\s+w\s+(?:ci[ąa]gu\s+)?(?:10|dziesi[ęe]ciu)\s+lat\b/i, + new RegExp(`\\b${RISK_NOUN_PL}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), + new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_PL}\\b`, "i"), + new RegExp(`\\b${RISK_NOUN_PL}\\b[^.?!]{0,40}\\b${PCT_WORD_PL}\\b`, "i"), + new RegExp(`\\b${PCT_WORD_PL}\\b[^.?!]{0,40}\\b${RISK_NOUN_PL}\\b`, "i"), + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_PL}`, "i"), + new RegExp(`${QUAL_NUM_PL}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_PL}`, "i"), ], }; @@ -488,8 +558,47 @@ function splitSentences(text: string): string[] { return text.split(/(?<=[.?!\n])\s+/); } -/** True when a dose-change imperative trips, honouring the continuation rule. */ -function doseTrips(subject: string, locale: Locale): boolean { +/** Every leading-magnitude dose value in one sentence ("keep your 7.5 mg" → [7.5]). */ +const DOSE_VALUE_RE = new RegExp(`([\\d.,]+)\\s*${DOSE_UNIT}\\b`, "gi"); +function sentenceDoseValues(sentence: string): number[] { + const values: number[] = []; + let m: RegExpExecArray | null; + DOSE_VALUE_RE.lastIndex = 0; + while ((m = DOSE_VALUE_RE.exec(sentence)) !== null) { + // Normalise a comma decimal ("7,5") and a stray thousands/decimal tail. + const cleaned = m[1].replace(/\.(?=\d{3}\b)/g, "").replace(",", "."); + const value = Number.parseFloat(cleaned); + if (Number.isFinite(value)) values.push(value); + } + return values; +} + +/** True when a dose value matches a scheduled dose (exact / ±2%). */ +function matchesSchedule( + values: readonly number[], + schedule: readonly number[], +): boolean { + return values.some((v) => + schedule.some((s) => Math.abs(v - s) <= Math.max(0.01, Math.abs(s) * 0.02)), + ); +} + +/** + * True when a dose-change imperative trips, honouring the continuation rule. + * + * v1.32.9 (Coach Guard II / G3 — M6/D7 end state): when `scheduleDoses` is + * supplied (the Coach passes the user's active doses), the continuation + * exemption ALSO requires the sentence's dose to match one the user is actually + * on. So "keep taking your 7.5 mg" passes only when 7.5 is a scheduled dose; + * "keep taking your 15 mg" when the schedule says 7.5 is a wrong maintenance + * dose and stays blocked. Without a schedule (every non-Coach surface) the + * Guard I phrase-anchored exemption stands unchanged. + */ +function doseTrips( + subject: string, + locale: Locale, + scheduleDoses?: readonly number[], +): boolean { const bank = BANKS.dose; const patterns = locale === "en" ? bank.en : [...bank[locale], ...bank.en]; const continuation = @@ -500,11 +609,18 @@ function doseTrips(subject: string, locale: Locale): boolean { locale === "en" ? [DOSE_CHANGE_STEM.en] : [DOSE_CHANGE_STEM[locale], DOSE_CHANGE_STEM.en]; + const gateOnSchedule = + scheduleDoses !== undefined && scheduleDoses.length > 0; for (const sentence of splitSentences(subject)) { if (!patterns.some((p) => p.test(sentence))) continue; - const exempt = + let exempt = continuation.some((p) => p.test(sentence)) && !changeStem.some((p) => p.test(sentence)); + if (exempt && gateOnSchedule) { + // The continuation phrasing is only trusted when the dose it names is one + // the user is actually on. An off-schedule maintenance dose is caught. + exempt = matchesSchedule(sentenceDoseValues(sentence), scheduleDoses); + } if (!exempt) return true; } return false; @@ -518,10 +634,21 @@ function doseTrips(subject: string, locale: Locale): boolean { * then risk, then causal. Each contract runs the reader's locale bank plus the * EN bank. */ +export interface ScreenOptions { + /** + * v1.32.9 — the user's active medication doses (numeric magnitudes). When + * present, the dose continuation exemption is additionally gated on a match: + * a "keep taking your N mg" is trusted only when N is a scheduled dose. Only + * the Coach passes this; every other surface keeps the phrase-anchored rule. + */ + scheduleDoses?: readonly number[]; +} + export function screenModelOutput( text: string, locale: Locale, contracts: readonly OutboundContract[], + opts?: ScreenOptions, ): OutboundDecision { const subject = text ?? ""; if (subject.trim().length === 0) return { block: false, reason: null }; @@ -530,7 +657,7 @@ export function screenModelOutput( if (contract === "dose") { // Dose is sentence-scoped so the continuation exemption cannot be // voided by a change stem in an unrelated sentence. - if (doseTrips(subject, locale)) { + if (doseTrips(subject, locale, opts?.scheduleDoses)) { return { block: true, reason: REASON_FOR_CONTRACT.dose }; } continue; diff --git a/src/lib/medications/scheduled-doses.ts b/src/lib/medications/scheduled-doses.ts new file mode 100644 index 000000000..fd22e1653 --- /dev/null +++ b/src/lib/medications/scheduled-doses.ts @@ -0,0 +1,46 @@ +/** + * v1.32.9 (Coach Guard II / G3) — the numeric doses on a user's active + * medication schedule, for the Grounding Ledger and the schedule-gated dose + * continuation exemption. + * + * `Medication.dose` is free text as printed on the container ("7.5 mg", + * "1 pieces", "0,25 mg"). It is deliberately NOT parsed into number + unit at + * write time (see the schema note), so this helper extracts the leading numeric + * magnitude the same way the display formatter does. The output is a de-duped + * set of magnitudes: the guard asks "is 7.5 a dose this user is actually on?", + * not "which medication", so the unit is irrelevant to the match. + * + * Server-only; a cheap indexed read over the user's active medications. + */ +import { prisma } from "@/lib/db"; + +/** Split on a leading number — mirrors `format-dose.ts` / `wizard-payload.ts`. */ +const DOSE_EXPR_RE = /^\s*([+-]?\d+(?:[.,]\d+)?)/; + +/** Parse the leading magnitude off a free-text dose, honouring a comma decimal. */ +function parseDoseValue(raw: string | null | undefined): number | null { + if (!raw) return null; + const match = DOSE_EXPR_RE.exec(raw.trim()); + if (!match) return null; + const value = Number.parseFloat(match[1].replace(",", ".")); + return Number.isFinite(value) ? value : null; +} + +/** + * The de-duped set of numeric doses across the user's ACTIVE medications. Empty + * when the user has no active medications or none carries a parseable dose. + */ +export async function getScheduledDoseValues( + userId: string, +): Promise { + const rows = await prisma.medication.findMany({ + where: { userId, active: true }, + select: { dose: true }, + }); + const values = new Set(); + for (const row of rows) { + const value = parseDoseValue(row.dose); + if (value !== null) values.add(value); + } + return [...values]; +} From 6333d4d9891c0df137b08661e47664706d1cd6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 23 Jul 2026 22:50:03 +0200 Subject: [PATCH 2/3] =?UTF-8?q?chore(release):=20v1.32.9=20=E2=80=94=20Coa?= =?UTF-8?q?ch=20Guard=20II=20(grounding=20ledger=20+=20action=20ladder)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Coach output guard now tracks the numbers it was shown across the whole conversation, so a figure carried forward or grounded in a goal, a schedule, or a reference band stops being marked unverified, while an invented one is still caught and a fabricated risk number now blocks in six languages. --- CHANGELOG.md | 26 +++++++++++++++++++++++++- docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f703f8600..7bd4500c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,31 @@ ## [Unreleased] -## [1.32.8] — 2026-07-23 +## [1.32.9] — 2026-07-23 + +The Coach output guard now remembers the numbers you were actually shown across +a whole conversation, so figures you legitimately carry forward stop getting +marked unverified while an invented one still gets caught. + +- **A figure from earlier in the chat is no longer flagged when you bring it + back up.** The guard now keeps a running record of every number the Coach was + given this turn and in earlier turns, plus your goal figures, your medication + doses, and the reference bands it was shown. A value it can trace to one of + those is left alone even a few turns later. A value the Coach only wrote in an + earlier reply is never trusted on that basis, so a number that once slipped + through cannot quietly become authoritative later. +- **An educational sentence keeps its numbers.** A line that states a general + guideline, like the note that adults usually need seven to nine hours of + sleep, is recognised as a reference and left intact instead of having its + numbers stripped. This works in every supported language. +- **A dose you are actually on reads as a restatement, a different one does + not.** When the Coach says to keep taking a dose, the reminder is allowed only + when the amount matches a dose on your schedule. A wrong maintenance amount, or + any wording that nudges the dose up or down, is still held back. +- **Fabricated risk numbers are caught in more languages.** The screen that + blocks an invented clinical risk figure or a named risk-engine result now + reads French, Spanish, Italian, and Polish to the same depth it already read + English and German, including a percentage written out in words. Health records written through the app now carry where they came from, and a HealthKit sync can say what set it off. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ba6cce1d8..dfb1ae2d9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.8 + version: 1.32.9 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 5587af24b..c6118fca8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.8", + "version": "1.32.9", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 3f14e5752..cf98f70fb 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.8"; + /* @sw-version-fallback */ "v1.32.9"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From dece1f89d918934b241360022f90de70cd06044f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 23 Jul 2026 22:52:53 +0200 Subject: [PATCH 3/3] docs(changelog): restore the 1.32.8 heading above its entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd4500c3..05bb61a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ marked unverified while an invented one still gets caught. reads French, Spanish, Italian, and Polish to the same depth it already read English and German, including a percentage written out in words. +## [1.32.8] — 2026-07-23 + Health records written through the app now carry where they came from, and a HealthKit sync can say what set it off.