diff --git a/CHANGELOG.md b/CHANGELOG.md index cde53d9cd..bf89ffce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ ## [Unreleased] +## [1.32.7] — 2026-07-23 + +The Coach output guard now reads numbers the way you actually write them, so a +correct figure stops getting marked unverified while an invented one still gets +caught. + +- **A number you reformatted is no longer flagged.** The guard recognises a + rounded restatement, a delta narrated as a drop, an adherence rate written as + a percent, minutes read back as hours, a reformatted date, and a + thousands-separated step count, each checked against the figure the server + actually gave the Coach. A drifted number, like an average of 128 quoted back + as 138, is still caught. +- **A blood-pressure claim can no longer borrow a weight number.** Every figure + is matched inside its own unit family, and a raw reading never earns the + looser rounding reserved for a headline average, so a fabricated average + cannot slip through on a nearby sample. +- **A correct clinical-risk refusal streams through untouched.** Naming ASCVD or + a ten-year risk to explain that a clinician computes it now passes. What still + blocks is an asserted figure or a categorical verdict, whether it carries + digits ("your risk is about 14%"), spelled-out words ("roughly twelve + percent"), or no number at all ("SCORE2 would put you in the high-risk band"). +- **A dose you already take reads as support.** "Keep taking your 7.5 mg as + prescribed" passes, while anything that moves the dose stays blocked. +- **A figure removed as unverifiable leaves the sentence intact.** The strip + touches only the exact token, so a year like 2023 is never clipped by a + flagged 23. + +Refs #587, #591. + ## [1.32.6] — 2026-07-23 Hardening and data-integrity fixes that were reviewed earlier and are now folded diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d7381d5b8..12a39b1c2 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.6 + version: 1.32.7 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 b5c8003a3..ce6797dda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.6", + "version": "1.32.7", "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 e0c6020b1..c62403b05 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.6"; + /* @sw-version-fallback */ "v1.32.7"; 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/*` diff --git a/src/app/api/insights/chat/__tests__/route-guard.test.ts b/src/app/api/insights/chat/__tests__/route-guard.test.ts new file mode 100644 index 000000000..8300f5645 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-guard.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.32.7 (Coach Guard I / G1) — route-level SSE assertions on the ASSEMBLED + * reply, the class the pure-module tests cannot see (the #591 lesson: the unit + * suite was green while the route regressed, because the guard runs inside + * `route.ts`). Unlike `route-tool-mode.test.ts`, this file uses the REAL + * outbound guard and the REAL numeric verifier — only the provider + IO are + * stubbed — so it exercises the guard exactly as production does, on both a + * tool turn and a no-tool turn. + * + * It asserts on the persisted assistant `content`, which is the fully-guarded + * text streamed to the client (every guard runs before the first token frame). + */ + +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() })); +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" })) }, + workout: { findFirst: vi.fn(async () => null) }, + }, +})); + +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; +}); + +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(async () => ({ id: "m1" })), + createConversation: vi.fn(async () => ({ id: "c1" })), + fetchConversationWithMessages: vi.fn(), + 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), +})); + +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-06-21"), + reserveBudget, + reconcileSpend, + resolveDailyCap: vi.fn(() => 2_000_000), +})); + +const { detectRefusal } = vi.hoisted(() => ({ + detectRefusal: vi.fn(() => ({ refuse: false })), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal })); + +// NB: the outbound guard + numeric verifier are REAL — that is the point. +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 { runCoachToolLoop } = vi.hoisted(() => ({ + runCoachToolLoop: vi.fn(), +})); +vi.mock("@/lib/ai/coach/tools", () => ({ + COACH_TOOL_DEFS: [{ name: "get_metric_series" }], + MAX_ROUNDS: 3, + buildCoachDataInventory: vi.fn(async () => ({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: ["bp"], window: "last30days" }, + })), + renderDataInventory: vi.fn(() => "DATA INVENTORY"), + renderFocusHint: vi.fn(() => ""), + 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"; +import { COACH_OUTBOUND_RISK_BLOCK_EN } from "@/lib/ai/coach/outbound-guard"; + +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), + }); +} + +/** The reply the model "returned" flows through the sentinel parsers verbatim. */ +function echo(content: string): void { + parseKeyValuesSentinel.mockReturnValue({ + prose: content, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + parseSuggestReminder.mockReturnValue({ prose: content }); +} + +/** Drive a TOOL turn returning `content` with the given tool payloads. */ +async function toolTurn( + content: string, + toolResults: Array<{ present: boolean; data: unknown }> = [], +): Promise { + echo(content); + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + runCoachToolLoop.mockResolvedValue({ + result: { content, tokensUsed: 80, model: "m" }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 1, + toolTrace: [{ name: "get_metric_series", present: true }], + toolResults, + }); + const res = await post(chatReq({ message: "How is my BP?" })); + await sse.done; + expect(res.status).toBe(200); + 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; +} + +/** Drive a NO-TOOL turn (a provider lacking tools) returning `content`. */ +async function noToolTurn(content: string): Promise { + echo(content); + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + { providerType: "local", instance: { supportsTools: false } }, + ]); + runStreamingRawCompletionWithFallback.mockResolvedValue({ + result: { content, tokensUsed: 42, model: "m" }, + workingProvider: { providerType: "anthropic" }, + }); + const res = await post(chatReq({ message: "How is my BP?" })); + await sse.done; + expect(res.status).toBe(200); + 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 — Guard I at the route level (G1)", () => { + beforeEach(() => { + vi.clearAllMocks(); + reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 }); + detectRefusal.mockReturnValue({ refuse: false }); + checkRateLimit.mockResolvedValue({ allowed: true }); + assertConsentForChain.mockResolvedValue(undefined); + }); + + it("replaces a fabricated risk assertion end-to-end (tool turn)", async () => { + const content = + "Based on SCORE2, your risk sits at about 14% given your numbers."; + const out = await toolTurn(content, [ + { present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } }, + ]); + expect(out).toBe(COACH_OUTBOUND_RISK_BLOCK_EN); + expect(out).not.toContain("14%"); + }); + + it("replaces a fabricated risk assertion end-to-end (no-tool turn)", async () => { + const out = await noToolTurn( + "Your ten-year cardiovascular risk is roughly twelve percent.", + ); + expect(out).toBe(COACH_OUTBOUND_RISK_BLOCK_EN); + expect(out).not.toContain("twelve percent"); + }); + + it("streams a model-perfect refusal intact — it is not blocked or mangled (tool turn)", async () => { + const content = + "I can't calculate a 10-year cardiovascular risk for you — an ASCVD score is something your clinician computes with lab values I don't have."; + const out = await toolTurn(content, [ + { present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } }, + ]); + expect(out).toBe(content); + expect(out).not.toContain("[unverified]"); + }); + + it("streams dates, ranges and thousands unmangled when they are grounded (tool turn)", async () => { + const content = + "On 2026-07-23 your systolic sat between 120 and 135 mmHg, with about 10,000 steps logged."; + const out = await toolTurn(content, [ + { + present: true, + data: { metric: "bp", section: { aggregate: { min: 121, max: 134 } } }, + }, + { + present: true, + data: { metric: "steps", section: { aggregate: { latest: 10000 } } }, + }, + ]); + expect(out).toBe(content); + expect(out).not.toContain("[unverified]"); + }); + + it("soft-strips a genuinely fabricated figure on a tool turn (the floor still holds)", async () => { + const content = "Your systolic averaged about 138 recently."; + const out = await toolTurn(content, [ + { + present: true, + data: { metric: "bp", section: { aggregate: { avgSys30: 128 } } }, + }, + ]); + expect(out).toContain("[unverified]"); + expect(out).not.toContain("138"); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 718a0a130..15c6867f5 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -927,6 +927,7 @@ async function handleChatRequest(request: NextRequest): Promise { const unverified = findUnverifiedCoachNumbers( replyText, authoritativePayloads, + locale, ); if (unverified.length > 0) { const { prose: corrected, stripped } = stripUnverifiedNumbers( diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts index d9150d489..1b1f19e6a 100644 --- a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts @@ -247,6 +247,202 @@ describe("findUnverifiedCoachNumbers", () => { }); }); +/* ────────────────────────────────────────────────────────────────────────── + * v1.32.7 (Coach Guard I / G1) — typed tokenizer + normal-form reconciler. + * Both directions: a figure the model legitimately reformatted must PASS, and + * every fabrication shape the red team named must STILL be flagged. + * ────────────────────────────────────────────────────────────────────────── */ +describe("G1 — normal-form reconciler grounded-passes", () => { + it("passes a headline integer adherence rate restated as a percent (compliance-block.ts:181-184)", () => { + // The compliance headline `rate` is an integer 0–100, not a ratio. + const payload = [{ metric: "adherence", section: { rate: 93 } }]; + expect( + findUnverifiedCoachNumbers("Your adherence was 93% this month.", payload), + ).toEqual([]); + }); + + it("passes a ratio headline restated as a percent (ratio↔percent in PERCENT space)", () => { + const payload = [{ metric: "adherence", section: { rate: 0.93 } }]; + expect( + findUnverifiedCoachNumbers( + "Your adherence has been about 93% overall.", + payload, + ), + ).toEqual([]); + }); + + it("passes a negative delta narrated as a positive drop (sign-insensitive)", () => { + const payload = [ + { metric: "weight", section: { aggregate: { deltaVs7: -1.2 } } }, + ]; + expect( + findUnverifiedCoachNumbers("You dropped 1.2 kg this week.", payload), + ).toEqual([]); + }); + + it("passes a colloquial nearest-10 rounding of an authoritative value", () => { + const payload = [ + { metric: "bp", section: { aggregate: { avgSys30: 124 } } }, + ]; + // round(124, nearest 10) === 120 — a canonical rounding, not a ±2% band + // (|120 - 124| = 4 exceeds the 2.48 tolerance, so only rounding grounds it). + expect( + findUnverifiedCoachNumbers("Your systolic averaged around 120.", payload), + ).toEqual([]); + }); + + it("passes a minutes→hours restatement of a sleep mean", () => { + const payload = [ + { metric: "sleep", section: { aggregate: { mean: 444 } } }, + ]; + // 444 min ⇔ 7.4 h. + expect( + findUnverifiedCoachNumbers( + "You averaged about 7.4 hours asleep.", + payload, + ), + ).toEqual([]); + }); + + it("parses an EN thousands-separated step count as one figure, not 10", () => { + const payload = [ + { metric: "steps", section: { aggregate: { latest: 10000 } } }, + ]; + expect( + findUnverifiedCoachNumbers( + "You hit 10,000 steps yesterday.", + payload, + "en", + ), + ).toEqual([]); + }); + + it("parses a DE thousands-separated step count using the reply language", () => { + const payload = [ + { metric: "steps", section: { aggregate: { latest: 10000 } } }, + ]; + expect( + findUnverifiedCoachNumbers( + "Du hast gestern 10.000 Schritte geschafft.", + payload, + "de", + ), + ).toEqual([]); + }); + + it("never grades a reformatted ISO date, a clock time, or a bare year", () => { + const payload = [ + { metric: "bp", section: { aggregate: { avgSys30: 128 } } }, + ]; + const prose = + "On 2026-07-23 at 22:45 your systolic sat at 128, the calmest since 2019."; + expect(findUnverifiedCoachNumbers(prose, payload)).toEqual([]); + }); + + it("passes a range narration against the real min/max (per-endpoint rounding)", () => { + const payload = [ + { metric: "bp", section: { aggregate: { min: 121, max: 134 } } }, + ]; + // round(121,5)=120 and round(134,5)=135 — both endpoints reconcile. + expect( + findUnverifiedCoachNumbers( + "Your systolic sat between 120 and 135 mmHg.", + payload, + ), + ).toEqual([]); + }); +}); + +describe("G1 — fabricated figures still blocked", () => { + it("blocks paraphrase drift: 128 must NOT ground '~138' (canonical rounding is anchored)", () => { + const payload = [ + { metric: "bp", section: { aggregate: { avgSys30: 128 } } }, + ]; + const findings = findUnverifiedCoachNumbers( + "Your systolic averaged about 138 recently.", + payload, + ); + expect(findings.map((f) => f.value)).toContain(138); + }); + + it("blocks a fabricated mean laundered via a raw sample's rounding (D5 aggregate-only scoping)", () => { + // A dense timeline carries an outlier sample 136.8; the true mean is 128. + // "around 140" = round(136.8,10) — but a SAMPLE never gets rounding. + const payload = [ + { + metric: "bp", + section: { + aggregate: { mean: 128 }, + timeline: [{ value: 118 }, { value: 136.8 }, { value: 129 }], + }, + }, + ]; + const findings = findUnverifiedCoachNumbers( + "Your systolic average is around 140.", + payload, + ); + expect(findings.map((f) => f.value)).toContain(140); + }); + + it("blocks an adherence % that is ±15 points off a single day's rate (M1 percent-space)", () => { + // A per-day rate 0.8 is a SAMPLE (inside a timeline array). Dividing 93% + // into 0.93 would ground it against 0.8 under a 0.15 band — the M1 bug. + const payload = [ + { + metric: "adherence", + section: { timeline: [{ rate: 0.8 }, { rate: 0.75 }] }, + }, + ]; + const findings = findUnverifiedCoachNumbers( + "Your adherence has been around 93%.", + payload, + ); + expect(findings.map((f) => f.value)).toContain(93); + }); + + it("blocks fabricated range endpoints 100–150 against a real min 121 / max 134 (D6)", () => { + const payload = [ + { metric: "bp", section: { aggregate: { min: 121, max: 134 } } }, + ]; + const findings = findUnverifiedCoachNumbers( + "Your systolic ranged from 100 to 150 mmHg.", + payload, + ); + const values = findings.map((f) => f.value); + expect(values).toContain(100); + expect(values).toContain(150); + }); + + it("blocks a fabricated figure equal to a payload date fragment (date-typing)", () => { + // The payload carries an ISO date string; its mined fragment (-23) must + // not ground a fabricated "23-point" claim. + const payload = [ + { + metric: "weight", + section: { lastReading: "2026-07-23", aggregate: { latest: 72.4 } }, + }, + ]; + const findings = findUnverifiedCoachNumbers( + "That is a 23 kg swing from where you were.", + payload, + ); + expect(findings.map((f) => f.value)).toContain(23); + }); + + it("blocks a fabricated pressure figure near an unrelated-kind leaf (kind-scoping)", () => { + // The only authoritative leaf is a weight; a mmHg claim of the same + // magnitude must NOT ground against it. + const payload = [ + { metric: "weight", section: { aggregate: { latest: 72 } } }, + ]; + const findings = findUnverifiedCoachNumbers( + "Your systolic averaged 72 mmHg this week.", + payload, + ); + expect(findings.map((f) => f.value)).toContain(72); + }); +}); + describe("stripUnverifiedNumbers", () => { it("replaces only the flagged tokens, leaving grounded numbers intact", () => { const prose = "Systolic 128, but it spiked to 138 yesterday."; @@ -263,4 +459,16 @@ describe("stripUnverifiedNumbers", () => { expect(prose).toBe("all good"); expect(stripped).toBe(0); }); + + it("is boundary-safe: a flagged '23' never clips a grounded '2023' (v1.32.7)", () => { + // The old plain indexOf produced "20[unverified]" here. + const prose = "Since 2023 your reading of 23 stood out."; + const { prose: out, stripped } = stripUnverifiedNumbers(prose, [ + { value: 23, source: "23" }, + ]); + expect(out).toContain("2023"); + expect(out).not.toContain("20[unverified]"); + expect(out).toContain("[unverified]"); + expect(stripped).toBe(1); + }); }); diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index e3052cd39..dd3dd144d 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -1,5 +1,6 @@ /** * 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. * * The Daily Briefing strips any number absent from the server-computed * `signalsOfDay` block (`@/lib/ai/briefing-grounding`). The Coach's TOOL path @@ -8,24 +9,43 @@ * tools actually returned this turn — so a transcription/paraphrase drift * (tool says systolic 128, prose says "~138") could ship. * - * This module closes that gap the same way, scoped to the tool path: - * 1. Collect every numeric leaf from this turn's PRESENT `CoachToolResult` - * data payloads — the authoritative figure set. - * 2. Extract every number the model's prose asserts (reusing the briefing - * verifier's `extractNumbers`). - * 3. Flag any prose number that matches no authoritative figure within a - * rounding tolerance, exempting the structural integers (window lengths, - * small ordinals) the briefing verifier already exempts. + * The original closure (v1.21.0 → v1.32.4) graded an UNTYPED bag of magnitudes + * with a bare number regex, and repaired misses with a plain `indexOf`. That + * mangled correct numbers the model merely reformatted (dates → junk fragments, + * `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: + * + * 1. A TYPED TOKENIZER classifies each prose number before it is graded — + * ISO / written dates, clock times, bare years, ordinals, list markers, + * range pairs, percents, thousands-separated integers, unit-suffixed + * measurements. Benign types (dates / times / years / ordinals / lists) + * are never graded and never stripped. A range decomposes into its two + * endpoint magnitudes, each graded independently (per-endpoint, no + * min/max "bracket" arm). Thousands separators follow the reply's actual + * language, not the UI locale. + * + * 2. A NORMAL-FORM RECONCILER grades a magnitude against a typed authoritative + * entry — value + kind + aggregation — instead of a flat number. An + * entry is grounded exactly / within ±2% always; sign-insensitive, + * canonical rounding, ratio↔percent (in percent space, never dividing the + * token down), and minutes↔hours widen the match ONLY for an AGGREGATE, + * KINDED entry (a headline mean / latest / delta — never a raw timeline + * sample, where the widenings would compound into a free band). Kind + * scoping means a weight value can never ground a blood-pressure claim. * * Posture: NON-BLOCKING and cheap. The caller annotates * `coach.prose.number_unverified` and may SOFT-STRIP the unverified figure - * from the prose (replacing the bare token with a neutral placeholder) — 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 prompt-level grounding rule - * remains the backstop, exactly like the briefing's "no signals → skip". + * 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 + * prompt-level grounding rule remains the backstop, exactly like the briefing's + * "no signals → skip". */ import { extractNumbers } from "@/lib/ai/briefing-grounding"; +import type { Locale } from "@/lib/i18n/config"; /** Absolute + relative tolerance — identical basis to the briefing verifier. */ const ABS_TOLERANCE = 0.15; @@ -54,153 +74,424 @@ export interface UnverifiedCoachNumber { source: string; } +/* ────────────────────────────────────────────────────────────────────────── + * Kinds — the unit family a figure belongs to. Kind scoping is what stops a + * fabricated systolic grounding against a numerically-nearby weight or a step + * count. An entry / token with an unknown kind is `null` (unkinded) and matches + * magnitude-only (exact / ±2%), never with the widening normal forms — so + * kinding is a monotone tightening path: every kind added strictly improves + * precision, never widens. + * ────────────────────────────────────────────────────────────────────────── */ +type NumKind = + "mass" | "pressure" | "pulse" | "percent" | "duration" | "count" | "glucose"; + +/** Resolve a kind from a unit suffix the prose attaches to a number. */ +function unitToKind(unitRaw: string): NumKind | null { + const u = unitRaw.toLowerCase().replace(/\s+/g, ""); + if (/^(?:kg|kilograms?|kgs|lb|lbs|pounds?)$/.test(u)) return "mass"; + if (u === "mmhg") return "pressure"; + if (u === "bpm") return "pulse"; + if (u === "%" || u === "percent" || u === "percent." || u === "percentage") + return "percent"; + if (/^(?:min|mins|minute|minutes|m|h|hr|hrs|hour|hours)$/.test(u)) + return "duration"; + if (/^(?:steps?)$/.test(u)) return "count"; + if (/^(?:mg\/dl|mmol\/l|mgdl|mmoll)$/.test(u)) return "glucose"; + return null; +} + +/** Resolve a kind from a payload KEY name (best-effort substring match). */ +function keyToKind(key: string): NumKind | null { + const k = key.toLowerCase(); + if (/sys|dia|systol|diastol|(?:^|[^a-z])bp(?:[^a-z]|$)|mmhg/.test(k)) + return "pressure"; + if (/weight|mass|bodyweight|kg\b/.test(k)) return "mass"; + if (/pulse|bpm|heart.?rate|restinghr|\brhr\b|resting/.test(k)) return "pulse"; + if (/sleep|asleep|awake|inbed|duration|minutesasleep/.test(k)) + return "duration"; + if (/step/.test(k)) return "count"; + if (/adherence|complian|\brate\b|percent/.test(k)) return "percent"; + if (/glucose|mgdl|mmol/.test(k)) return "glucose"; + return null; +} + +/** Resolve a kind from a payload's `metric` / `type` discriminator. */ +function metricToKind(metric: string): NumKind | null { + const m = metric.toLowerCase(); + if (/weight|mass|bodyweight/.test(m)) return "mass"; + if (/^bp$|blood.?pressure|systol|diastol/.test(m)) return "pressure"; + if (/pulse|heart.?rate|\bhr\b|resting/.test(m)) return "pulse"; + if (/sleep/.test(m)) return "duration"; + if (/step/.test(m)) return "count"; + if (/adherence|complian/.test(m)) return "percent"; + if (/glucose/.test(m)) return "glucose"; + return null; +} + /** - * Recursively collect every finite numeric leaf from a tool-result payload — - * numbers, and numeric strings ("128", "1.2"). Both forms appear in the - * snapshot sections (a mean may be a number; a lab value a string). The set is - * deliberately broad so a legitimately-cited figure never trips the verifier; - * the verifier's job is only to catch a number the model invented or mis-copied. + * v1.21.0 — the small, named "central tendency" fields every snapshot block + * exposes (`latest`, `avg7`, `avgSys30`, `allTimeAvg`, …). A value reached + * under one of these keys (and NOT inside a timeline array) is an AGGREGATE — + * the widening normal forms apply to it. A raw sample, a per-day rate, a + * per-night value (all inside arrays), or a value under any other key is a + * SAMPLE, graded exact / ±2% only, because over a dense series the widenings + * compound into the free band the reconciler forswears. */ -export function collectNumericLeaves(value: unknown, out: Set): void { +const AGGREGATE_POINT_KEY = + /^(?:latest|current|mean|median|avg(?:Sys|Dia)?\d*(?:LastMonth|LastYear)?|average|allTime(?:Avg|Min|Max)(?:Sys|Dia)?|min|max|spread\d*|delta(?:Vs)?\d*|rate|headline)$/i; + +/** 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. */ +interface AuthEntry { + value: number; + kind: NumKind | null; + aggregation: "aggregate" | "sample"; +} + +/** + * 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` + * discriminator first, then the enclosing key name; `aggregation` from whether + * the value sits under an aggregate-point key and outside every array. + */ +function collectEntries( + value: unknown, + out: AuthEntry[], + ctx: { metricKind: NumKind | null; keyHint: string | null; inArray: boolean }, +): void { if (value === null || value === undefined) return; if (typeof value === "number") { - if (Number.isFinite(value)) out.add(value); + if (!Number.isFinite(value)) return; + const keyKind = ctx.keyHint ? keyToKind(ctx.keyHint) : null; + const aggregate = + !ctx.inArray && + ctx.keyHint !== null && + AGGREGATE_POINT_KEY.test(ctx.keyHint); + out.push({ + value, + kind: ctx.metricKind ?? keyKind, + aggregation: aggregate ? "aggregate" : "sample", + }); return; } if (typeof value === "string") { // A scalar numeric string ("128", "-1.2", "92%") — pull its magnitudes. - for (const { value: n } of extractNumbers(value)) out.add(n); - return; - } - if (Array.isArray(value)) { - for (const item of value) collectNumericLeaves(item, out); - return; - } - if (typeof value === "object") { - for (const v of Object.values(value as Record)) { - collectNumericLeaves(v, out); - } - } -} - -/** True when `value` matches any authoritative figure within tolerance. */ -function isGrounded( - value: number, - authoritative: ReadonlySet, -): boolean { - for (const a of authoritative) { - const tol = Math.max(ABS_TOLERANCE, Math.abs(a) * REL_TOLERANCE); - if (Math.abs(value - a) <= tol) return true; - } - return false; -} - -/** - * v1.32.1 (false-positive closure) — the small, named "central tendency" - * fields every snapshot block already exposes (`latest`, `avg7`, `avg30`, - * `avgSys30`, `allTimeAvg`, …). A model correctly narrating "down 3.5 kg from - * your 7-day average" is computing a DELTA between two of these — a - * legitimate derivation, not a fabrication — but the raw figure `3.5` is not - * itself a leaf anywhere in the payload, so the plain leaf-matching above - * flags it. Deliberately an ALLOWLIST of field names, not "every numeric - * leaf": a whole month of daily timeline samples would blow the pairwise - * derivation up combinatorially (see `deriveArithmeticValues`) and start - * matching a genuinely fabricated figure by coincidence, which is exactly the - * hole this checker exists to close. A raw sample, a count, or a day-offset - * never matches this pattern. - */ -const AGGREGATE_POINT_KEY = - /^(?:latest|mean|median|avg(?:Sys|Dia)?\d*(?:LastMonth|LastYear)?|allTime(?:Avg|Min|Max)(?:Sys|Dia)?)$/; - -/** - * Defensive cap on how many aggregate points one payload may contribute to - * the pairwise derivation below. The named fields the allowlist targets are a - * handful per metric block (2-6); a payload that somehow surfaces more than - * this is not the small aggregate shape the heuristic targets, so skip - * deriving from it rather than pay an unbounded O(n^2). - */ -const MAX_AGGREGATE_POINTS = 12; - -/** Collect every numeric leaf reachable under an `AGGREGATE_POINT_KEY` field. */ -function collectAggregatePoints( - value: unknown, - out: Set, - keyHint?: string, -): void { - if (value === null || value === undefined) return; - if (typeof value === "number") { - if ( - Number.isFinite(value) && - keyHint !== undefined && - AGGREGATE_POINT_KEY.test(keyHint) - ) { - out.add(value); + for (const { value: n } of extractNumbers(value)) { + const keyKind = ctx.keyHint ? keyToKind(ctx.keyHint) : null; + out.push({ + value: n, + kind: ctx.metricKind ?? keyKind, + // A number embedded in a string is not a clean aggregate point. + aggregation: "sample", + }); } return; } if (Array.isArray(value)) { - for (const item of value) collectAggregatePoints(item, out, keyHint); + for (const item of value) { + collectEntries(item, out, { ...ctx, inArray: true }); + } return; } if (typeof value === "object") { - for (const [k, v] of Object.entries(value as Record)) { - collectAggregatePoints(v, out, k); + // Pick up a `metric` / `type` discriminator to kind every leaf beneath it. + let metricKind = ctx.metricKind; + const record = value as Record; + for (const disc of ["metric", "type", "kind", "measure"]) { + const v = record[disc]; + if (typeof v === "string") { + const k = metricToKind(v); + if (k) metricKind = k; + } + } + for (const [k, v] of Object.entries(record)) { + collectEntries(v, out, { metricKind, keyHint: k, inArray: false }); } } } +/** Collect every aggregate-point magnitude under one payload (for derivation). */ +function collectAggregatePoints(entries: readonly AuthEntry[]): number[] { + const out = new Set(); + for (const e of entries) { + if (e.aggregation === "aggregate") out.add(e.value); + } + return [...out]; +} + /** * The delta, midpoint average, and percent-change between every PAIR of a * SINGLE payload's own aggregate points — the shapes a model legitimately * narrates ("down 3.5 kg from your 7-day average", "roughly a 5% drop"). - * Scoped per payload (one tool result / the pinned workout block / one - * inventory row), never pooled across the whole turn's authoritative set, so - * a weight delta cannot ground itself against an unrelated blood-pressure - * figure that happens to land nearby. + * Registered UNKINDED (a percent-change is not the points' kind) and graded + * exact / ±2% only, and never pooled across payloads, so a weight delta cannot + * ground itself against an unrelated blood-pressure figure nearby. */ -function deriveArithmeticValues(payload: unknown): number[] { - const points = new Set(); - collectAggregatePoints(payload, points); - const arr = [...points]; - if (arr.length < 2 || arr.length > MAX_AGGREGATE_POINTS) return []; - const derived: number[] = []; - for (let i = 0; i < arr.length; i += 1) { - for (let j = i + 1; j < arr.length; j += 1) { - const a = arr[i]; - const b = arr[j]; +function deriveArithmeticEntries(points: readonly number[]): AuthEntry[] { + if (points.length < 2 || points.length > MAX_AGGREGATE_POINTS) return []; + const derived: AuthEntry[] = []; + const add = (value: number) => + derived.push({ value, kind: null, aggregation: "aggregate" }); + for (let i = 0; i < points.length; i += 1) { + for (let j = i + 1; j < points.length; j += 1) { + const a = points[i]; + const b = points[j]; const diff = Math.abs(a - b); - derived.push(diff); - derived.push((a + b) / 2); - if (a !== 0) derived.push((diff / Math.abs(a)) * 100); - if (b !== 0) derived.push((diff / Math.abs(b)) * 100); + add(diff); + add((a + b) / 2); + if (a !== 0) add((diff / Math.abs(a)) * 100); + if (b !== 0) add((diff / Math.abs(b)) * 100); } } return derived; } +/* ────────────────────────────────────────────────────────────────────────── + * Reconciler — grade one typed prose magnitude against the authoritative set. + * ────────────────────────────────────────────────────────────────────────── */ + +/** True when `token` rounds to `anchor` at the nearest 1 / 5 / 10. */ +function isCanonicalRounding(token: number, anchor: number): boolean { + for (const step of [1, 5, 10]) { + if (Math.round(anchor / step) * step === token) return true; + } + return false; +} + +/** Exact / ±2% magnitude match — the floor every entry allows. */ +function withinTolerance(token: number, anchor: number): boolean { + const tol = Math.max(ABS_TOLERANCE, Math.abs(anchor) * REL_TOLERANCE); + return Math.abs(token - anchor) <= tol; +} + /** - * v1.32.1 (false-positive closure) — mask date-ordinal and numbered-list - * markers OUT of a working copy of the prose before it is tokenised for - * numbers, so "on July 21st" or a "1. Reduce sodium" recommendation line never - * reads as a restated health figure. `extractNumbers` has no notion of - * surrounding context, so this pre-processing happens on a throwaway copy — - * the ORIGINAL prose (and hence `stripUnverifiedNumbers`, which edits the real - * text via the flagged token) is untouched. Deliberately high precision: - * - a number immediately followed by an English ordinal suffix (21st, 3rd, - * 142nd) is a day-of-month or a rank, never a restated magnitude; - * - a number at the START of a line followed by ". " (a numbered-list - * marker) is masked, but ONLY at line-start with a space right after the - * dot — "Systolic 128. Blood pressure remained stable." keeps its 128 - * because that number does not open its own line, and "3.5 kg" keeps its - * figure because a digit (not whitespace) follows the dot. + * True when the typed prose magnitude reconciles against any authoritative + * entry. Kind scoping: a kinded token never matches a differently-kinded + * entry. Widening forms (sign, rounding, ratio↔percent, minutes↔hours) fire + * only for an aggregate, kinded entry. */ -function extractClaimableNumbers( - prose: string, -): Array<{ value: number; raw: string }> { - const masked = prose - .replace(/\b\d{1,3}(?:st|nd|rd|th)\b/gi, "DATE") - .replace(/^(\s*)\d{1,3}\.(\s)/gm, "$1LIST$2"); - return extractNumbers(masked); +function reconciles( + token: { value: number; kind: NumKind | null }, + entries: readonly AuthEntry[], +): boolean { + for (const entry of entries) { + // Kind gate — only rejects when BOTH sides are confidently kinded. + if (token.kind !== null && entry.kind !== null && token.kind !== entry.kind) + continue; + + // Exact / ±2% is always available (this is the unkinded / sample floor). + if (withinTolerance(token.value, entry.value)) return true; + + // Widening forms apply ONLY to an aggregate, kinded entry. + if (entry.aggregation !== "aggregate" || entry.kind === null) continue; + + // Sign-insensitive — a delta narrated as a positive "drop". + if (withinTolerance(Math.abs(token.value), Math.abs(entry.value))) + return true; + + // Canonical rounding — anchored to the authoritative value, never a band. + if (isCanonicalRounding(token.value, entry.value)) return true; + + // Ratio↔percent — normalise INTO percent space (multiply the ratio up), + // never divide the token down (dividing turns ±0.15 into ±15 points). + if ( + (entry.kind === "percent" || token.kind === "percent") && + entry.value > 0 && + entry.value <= 1 + ) { + if (withinTolerance(token.value, entry.value * 100)) return true; + } + + // Minutes↔hours — a per-day duration narrated in hours (444 min ⇔ 7.4 h). + if (entry.kind === "duration") { + if (withinTolerance(token.value, entry.value / 60)) return true; + } + } + return false; +} + +/* ────────────────────────────────────────────────────────────────────────── + * Typed tokenizer — classify each prose number before grading. Benign types + * (date / time / year / ordinal / list marker) are dropped; magnitudes + * (percent / measurement / count / range endpoint / plain) are returned for + * grading with their unit-derived kind. + * ────────────────────────────────────────────────────────────────────────── */ + +interface ProseMagnitude { + value: number; + raw: string; + kind: NumKind | null; +} + +const MONTHS = + "january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec|januar|februar|märz|maerz|april|mai|juni|juli|august|september|oktober|november|dezember"; + +/** Parse a raw number token honouring the reply language's separators. */ +function parseLocaleNumber(raw: string, locale: Locale): number | null { + let normalised: string; + if (locale === "en") { + // EN: "," groups thousands, "." is the decimal point. + normalised = raw.replace(/,/g, ""); + } else { + // DE / fr / es / it / pl: "." groups thousands, "," is the decimal point. + normalised = raw.replace(/\./g, "").replace(/,/g, "."); + } + const value = Number.parseFloat(normalised); + return Number.isFinite(value) ? value : null; +} + +/** + * Tokenize the prose into gradeable magnitudes. Everything that is positively + * typed as benign — an ISO or written date, a clock time, a bare year, an + * English ordinal, or a numbered-list marker — is skipped, so it is never + * graded and never stripped. Range pairs decompose into two endpoint + * magnitudes. + */ +function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { + const out: ProseMagnitude[] = []; + // Every character index already consumed by a benign / structural token, so + // a later pass does not re-read its digits as a bare magnitude. + const consumed = new Array(prose.length).fill(false); + const claim = (start: number, end: number): boolean => { + for (let i = start; i < end; i += 1) if (consumed[i]) return false; + for (let i = start; i < end; i += 1) consumed[i] = true; + return true; + }; + + const numFrag = + locale === "en" + ? String.raw`[+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?|[+-]?\d+(?:\.\d+)?` + : String.raw`[+-]?\d{1,3}(?:\.\d{3})+(?:,\d+)?|[+-]?\d+(?:,\d+)?`; + + const runPass = (re: RegExp, handle: (m: RegExpExecArray) => void): void => { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(prose)) !== null) { + if (m[0].length === 0) { + re.lastIndex += 1; + continue; + } + handle(m); + } + }; + + // (1) ISO dates — the whole span is benign. + runPass(/\b\d{4}-\d{2}-\d{2}\b/g, (m) => { + claim(m.index, m.index + m[0].length); + }); + + // (2) Clock times — "22:45", "7:30:00". + runPass(/\b\d{1,2}:\d{2}(?::\d{2})?\b/g, (m) => { + claim(m.index, m.index + m[0].length); + }); + + // (3) Written dates — "July 21st", "21. Juli", "6 May", "Mai 6". + runPass( + new RegExp( + String.raw`\b(?:(?:${MONTHS})\s+\d{1,2}(?:st|nd|rd|th)?|\d{1,2}\.?\s+(?:${MONTHS}))\b`, + "gi", + ), + (m) => { + claim(m.index, m.index + m[0].length); + }, + ); + + // (4) English ordinals — "21st", "3rd" (a day-of-month or a rank). + runPass(/\b\d{1,3}(?:st|nd|rd|th)\b/gi, (m) => { + claim(m.index, m.index + m[0].length); + }); + + // (5) Numbered-list markers at line start — "1. Cut back on sodium". + runPass(/(?:^|\n)[ \t]*\d{1,3}\.[ \t]/g, (m) => { + claim(m.index, m.index + m[0].length); + }); + + // (5b) Hyphenated time-span adjectives — "10-year", "7-day", "12-week". The + // number is a window length, never a health magnitude. Hours / minutes + // are deliberately excluded so a real duration ("7.4 hours") is graded. + runPass(/\b\d{1,3}-(?:year|yr|month|week|day)s?\b/gi, (m) => { + claim(m.index, m.index + m[0].length); + }); + + // (6) Bare years — 19xx / 20xx standing alone (not part of a larger number + // or a decimal; a trailing sentence comma / period is fine). + runPass(/(? { + claim(m.index, m.index + m[0].length); + }); + + // (7) Range pairs — "60-100", "between 120 and 135", "from 120 to 135". Both + // endpoints are decomposed into magnitudes graded independently. The unit + // that trails the pair kinds both endpoints. + const rangeUnit = String.raw`(?:\s*(mmHg|bpm|kg|mg\/dl|mmol\/l|%|steps?|minutes?|mins?|hours?|hrs?|hr))?`; + const dashRange = new RegExp( + String.raw`\b(\d+(?:[.,]\d+)?)\s*(?:-|–|—|to|bis)\s*(\d+(?:[.,]\d+)?)${rangeUnit}`, + "gi", + ); + runPass(dashRange, (m) => { + if (!claim(m.index, m.index + m[0].length)) return; + 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 }); + } + }); + const wordRange = new RegExp( + String.raw`\b(?:between|from|zwischen|von)\s+(\d+(?:[.,]\d+)?)\s+(?:and|to|und|bis)\s+(\d+(?:[.,]\d+)?)${rangeUnit}`, + "gi", + ); + runPass(wordRange, (m) => { + if (!claim(m.index, m.index + m[0].length)) return; + 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 }); + } + }); + + // (8) Percents — "93%", "93 percent". + runPass( + new RegExp(String.raw`(${numFrag})\s*(%|percent|per\s+cent|prozent)`, "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: "percent" }); + }, + ); + + // (9) Unit-suffixed measurements — "72.4 kg", "128 mmHg", "10,000 steps". + const unitFrag = + "kg|kilograms?|lbs?|pounds?|mmHg|bpm|mg\\/dl|mmol\\/l|steps?|minutes?|mins?|hours?|hrs?|hr"; + 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]) }); + }); + + // (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 }); + }); + + return out; +} + +/** + * Recursively collect every finite numeric leaf from a tool-result payload as + * a plain magnitude set. Retained for callers that only need magnitudes (the + * eval harness); the runtime verifier uses the typed `collectEntries` above. + */ +export function collectNumericLeaves(value: unknown, out: Set): void { + const entries: AuthEntry[] = []; + collectEntries(value, entries, { + metricKind: null, + keyHint: null, + inArray: false, + }); + for (const e of entries) out.add(e.value); } /** @@ -210,29 +501,39 @@ function extractClaimableNumbers( * cited number is grounded. * * `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). */ export function findUnverifiedCoachNumbers( prose: string, toolPayloads: ReadonlyArray, + locale: Locale = "en", ): UnverifiedCoachNumber[] { if (!prose) return []; - const authoritative = new Set(); + const authoritative: AuthEntry[] = []; for (const payload of toolPayloads) { - collectNumericLeaves(payload, authoritative); - // Widen the authoritative set with the derived figures a model may - // legitimately compute from this payload's own headline numbers, without - // pooling across unrelated payloads (see `deriveArithmeticValues`). - for (const d of deriveArithmeticValues(payload)) authoritative.add(d); + 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)), + ); } // No authoritative figures (a qualitative turn / no-tools path) — nothing to // grade against. The prompt-level grounding rule remains the backstop. - if (authoritative.size === 0) return []; + if (authoritative.length === 0) return []; const findings: UnverifiedCoachNumber[] = []; - for (const { value, raw } of extractClaimableNumbers(prose)) { - if (isStructural(value, raw)) continue; - if (isGrounded(value, authoritative)) continue; - findings.push({ value, source: raw.slice(0, 32) }); + 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; } @@ -348,7 +649,11 @@ export function hasThresholdVerdict(prose: string): boolean { * placeholder so a drifted figure never reaches the user as if authoritative, * while the surrounding qualitative framing is preserved. Conservative — it * only rewrites the exact ungrounded tokens the verifier flagged, and only the - * first occurrence of each, leaving every grounded number untouched. + * first occurrence of each. + * + * Boundary-safe (v1.32.7): the flagged token is matched only where it is NOT + * embedded in a larger number, so a flagged "23" can never clip "20[unverified]" + * out of a grounded "2023". The old plain `indexOf` had that exact bug. * * Returns the (possibly unchanged) prose plus the count of tokens stripped. */ @@ -360,11 +665,18 @@ export function stripUnverifiedNumbers( let out = prose; let stripped = 0; for (const f of findings) { - // Replace the first standalone occurrence of the flagged token. The token - // is bounded by non-digit / non-sign edges so we don't clip a larger number. const token = f.source; - const idx = out.indexOf(token); - if (idx === -1) continue; + if (token.length === 0) continue; + // Match the token bounded by non-digit / non-separator edges so a larger + // number that merely contains it is never clipped. + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Not embedded in a larger number (a leading/trailing digit or a + // separator-then-digit is part of a decimal / thousands run) — but a + // trailing sentence comma or period is fine. + const bounded = new RegExp(`(? { + it("blocks a named engine paired with a fabricated number", () => { const d = screenModelOutput( - "Based on SCORE2, your cardiovascular risk profile looks elevated.", + "Based on SCORE2, your risk sits at about 14%.", "en", CONVERSATIONAL_CONTRACTS, ); @@ -142,17 +142,177 @@ describe("screenModelOutput — issue #587 ordinary wellness-score wording", () expect(d.reason).toBe("risk_score"); }); - it("still blocks Framingham, ASCVD, and QRISK by name", () => { + it("blocks a named engine paired with a categorical result verdict (numberless)", () => { for (const engine of [ - "your Framingham result was concerning", - "the ASCVD estimate points to elevated risk", - "your QRISK figure came back high", + "Based on Framingham, you fall into the intermediate-risk category.", + "SCORE2 would put you in the high-risk band given your profile.", + "QRISK classifies you as elevated-risk group.", ]) { const d = screenModelOutput(engine, "en", CONVERSATIONAL_CONTRACTS); expect(d.block).toBe(true); expect(d.reason).toBe("risk_score"); } }); + + it("PASSES a bare engine mention with no asserted result or figure (D1/D4)", () => { + // Naming what ASCVD is — education or a refusal — is not a fabrication. + for (const mention of [ + "I can't compute your ASCVD, that's for your clinician.", + "An ASCVD score is something a doctor calculates from lab values.", + "Framingham and QRISK are two examples of cardiovascular risk tools.", + ]) { + const d = screenModelOutput(mention, "en", CONVERSATIONAL_CONTRACTS); + expect(d.block).toBe(false); + expect(d.reason).toBeNull(); + } + }); +}); + +// ── D1/D4 — hedge-then-assert, spelled-out percent, model-perfect refusal ── + +describe("screenModelOutput — risk narrowing (D1/D4/M4)", () => { + it("blocks the hedge-then-assert bypass (H1) — the 14% is caught", () => { + const d = screenModelOutput( + "I can't compute your exact ASCVD score, but based on your numbers your risk is about 14%.", + "en", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("risk_score"); + }); + + it("blocks a spelled-out percent risk figure (H4b)", () => { + for (const text of [ + "Your ten-year cardiovascular risk is roughly twelve percent.", + "Your ten-year risk is roughly twelve percent.", + ]) { + const d = screenModelOutput(text, "en", CONVERSATIONAL_CONTRACTS); + expect(d.block).toBe(true); + expect(d.reason).toBe("risk_score"); + } + }); + + it("blocks a numberless categorical engine result (H4a)", () => { + const d = screenModelOutput( + "SCORE2 would put you in the high-risk band.", + "en", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("risk_score"); + }); + + it("blocks a numberless categorical risk-level verdict on the horizon", () => { + const d = screenModelOutput( + "Your 10-year cardiovascular risk is elevated — consider stepping your dose up.", + "en", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("risk_score"); + }); + + it("PASSES a model-perfect refusal that names the horizon and the engine (M4)", () => { + const d = screenModelOutput( + "I can't calculate a 10-year cardiovascular risk for you — an ASCVD score is something your clinician computes with lab values I don't have.", + "en", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(false); + expect(d.reason).toBeNull(); + }); + + it("blocks a DE spelled-out percent risk figure and a DE engine result", () => { + expect( + screenModelOutput( + "Dein Risiko liegt bei etwa zwölf Prozent.", + "de", + CONVERSATIONAL_CONTRACTS, + ).block, + ).toBe(true); + expect( + screenModelOutput( + "SCORE2 würde dich in den hohen Risikobereich einordnen.", + "de", + CONVERSATIONAL_CONTRACTS, + ).block, + ).toBe(true); + }); + + it("PASSES a DE bare-horizon refusal with no figure", () => { + const d = screenModelOutput( + "Ein 10-Jahres-Risiko kann ich dir nicht berechnen, das macht deine Ärztin.", + "de", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(false); + }); +}); + +// ── D7 — dose continuation-exclusion ───────────────────────────────────── + +describe("screenModelOutput — dose continuation-exclusion (D7)", () => { + it("PASSES an adherence-supporting continuation of the current dose", () => { + const d = screenModelOutput( + "You should keep taking your prescribed 7.5 mg exactly as directed.", + "en", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(false); + expect(d.reason).toBeNull(); + }); + + it("still blocks 'keep in mind that trying 5 mg' — no maintenance anchor", () => { + const d = screenModelOutput( + "You should keep in mind that trying 5 mg could help.", + "en", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("dose_prescription"); + }); + + it("still blocks 'continue tapering to 2.4 mg' — a change stem voids the exemption", () => { + for (const text of [ + "You should continue tapering to 2.4 mg next week.", + "Continue tapering to 2.4 mg next week.", + ]) { + const d = screenModelOutput(text, "en", CONVERSATIONAL_CONTRACTS); + expect(d.block).toBe(true); + expect(d.reason).toBe("dose_prescription"); + } + }); + + it("still blocks 'you should try 5 mg' — exclusion is of continuation, not requirement of change", () => { + const d = screenModelOutput( + "You should try 5 mg and see how it feels.", + "en", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("dose_prescription"); + }); + + it("PASSES a DE continuation that trips a DE dose pattern but is anchored", () => { + // "du solltest … 7,5 mg" trips the DE consider/should dose pattern; the + // "weiterhin … wie verordnet" maintenance anchor exempts it. + const d = screenModelOutput( + "Du solltest weiterhin deine 7,5 mg wie verordnet einnehmen.", + "de", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(false); + }); + + it("still blocks a DE dose increase even with a continuation word present", () => { + const d = screenModelOutput( + "Du solltest weiterhin erhöhen, und zwar auf 5 mg.", + "de", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("dose_prescription"); + }); }); // ── causal claims (GROUND RULE 12) ─────────────────────────────────────── diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts index 1be3a7210..fcb3c7a79 100644 --- a/src/lib/ai/safety/outbound-screen.ts +++ b/src/lib/ai/safety/outbound-screen.ts @@ -82,9 +82,9 @@ const DOSE_PATTERNS: Record = { `\\b(?:step|move|increase|raise|bump|titrat\\w*|go|ramp|push|up)\\s+(?:it\\s+)?(?:up\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, "i", ), - // lower/reduce/cut/drop/decrease/back off to|by N unit + // lower/reduce/cut/drop/decrease/back off/taper(ing) to|by N unit new RegExp( - `\\b(?:lower|reduce|cut|drop|decrease|back\\s+off|taper)\\s+(?:it\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:lower|reduce|cut|drop|decrease|back\\s+off|taper\\w*)\\s+(?:it\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, "i", ), // consider/try/should/recommend ... N unit @@ -209,25 +209,119 @@ const DOSE_PATTERNS: Record = { * Risk-score fabrication banks. The model is grounded on a server-computed * snapshot and must never invent a clinical risk percentage or a named * risk-engine score — those are numbers the server never computed. + * + * v1.32.7 (Coach Guard I — D1/D4). The bare-engine and bare-horizon patterns + * are GONE: a bare mention ("an ASCVD score is what your clinician computes") + * is education or a refusal, exactly what the system prompt asks for, and the + * old bank blocked it — the "generic clinical-risk refusal" loop the + * maintainer kept hitting. A fabrication is the ASSERTION, not the mention: + * + * - a qualifying NUMBER — a digit percent, a spelled-out percent word + * ("roughly twelve percent"), or "score of N" — attached to a risk noun, + * the 10-year horizon phrase, or a named engine, OR + * - a categorical engine/horizon RESULT — "SCORE2 would put you in the + * high-risk band" — even with no digits. + * + * The horizon token itself ("10-year … risk") is NOT a qualifying number, so a + * model-perfect refusal that names it passes with no exemption. There is + * deliberately no refusal-context exemption (it was a hedge-then-assert bypass: + * "I can't compute your ASCVD, but your risk is about 14%"). */ +// Spelled-out cardinal numbers (0–99) so a digit-less "twelve percent" still +// counts as a fabricated figure, not an educational aside. +const SPELLED_EN = + "(?:zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)(?:[- ](?:one|two|three|four|five|six|seven|eight|nine))?"; +const PCT_WORD_EN = `${SPELLED_EN}\\s+(?:percent|per\\s+cent)`; +const QUAL_NUM_EN = `(?:\\d{1,3}\\s*%|${PCT_WORD_EN}|score\\s+of\\s+\\d)`; +const RISK_NOUN_EN = "(?:risk|chance|probability|likelihood)"; +const ENGINE = "(?:framingham|ascvd|score2|qrisk)"; +const HORIZON_EN = + "(?:10[- ]year|ten[- ]year|lifetime)\\s+(?:cardiovascular|cardiac|heart|stroke|mortality|cvd|ascvd)\\s+risk"; +const RESULT_VERB_EN = + "(?:puts?\\s+you|would\\s+put\\s+you|placing\\s+you|places?\\s+you|you\\s+fall|you'?d\\s+fall|classif\\w*\\s+you)"; +const RISK_BAND_EN = + "(?:high|higher|intermediate|elevated|moderate|low|borderline)[- ]?risk\\s+(?:band|category|group|range)"; +// A categorical VERDICT on the engine / horizon itself ("your 10-year risk IS +// elevated") — a fabricated result with no digits. The horizon phrase and a +// named engine are inherently about-this-user, so an attached risk-level +// adjective is an assertion, not education. A model-perfect refusal names the +// horizon but attaches no such adjective, so it still passes. +const RISK_LEVEL_EN = + "(?:elevated|high|higher|intermediate|moderate|borderline|raised|concerning|significant)"; +const RISK_VERDICT_EN = `(?:is|are|looks?|appears?|seems?|sits?|remains?|comes?\\s+back|runs?|suggests?|indicat\\w*|shows?|reflects?|points?\\s+to)\\s+(?:\\w+\\s+){0,2}${RISK_LEVEL_EN}`; +const SPELLED_DE = + "(?:null|eins?|zwei|drei|vier|fünf|sechs|sieben|acht|neun|zehn|elf|zwölf|dreizehn|vierzehn|fünfzehn|sechzehn|siebzehn|achtzehn|neunzehn|zwanzig|dreißig|vierzig|fünfzig|sechzig|siebzig|achtzig|neunzig)"; +const PCT_WORD_DE = `${SPELLED_DE}\\s+prozent`; +const QUAL_NUM_DE = `(?:\\d{1,3}\\s*%|${PCT_WORD_DE})`; +const RISK_NOUN_DE = "(?:risiko|wahrscheinlichkeit|chance)"; +const HORIZON_DE = "(?:10[- ]jahres|zehn[- ]jahres|lebenszeit)[- ]?risiko"; +const RESULT_VERB_DE = + "(?:ordnet\\s+(?:dich|sie)\\s+ein|stuft\\s+(?:dich|sie)\\s+ein|f[äa]llst\\s+in|einordnen|liegst\\s+im)"; +const RISK_BAND_DE = + "(?:hoh|niedrig|mittler|erhöht|moderat|gering)\\w*[- ]?risiko(?:bereich|kategorie|gruppe|band)"; +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}`; + const RISK_PATTERNS: Record = { en: [ - /\b\d{1,3}\s*%\s+(?:risk|chance|probability|likelihood)\b/i, - /\b(?:risk|chance|probability|likelihood)\s+(?:of|is|at)\s+(?:about\s+|roughly\s+|~)?\d{1,3}\s*%/i, - /\b(?:10[- ]year|ten[- ]year|lifetime)\s+(?:cardiovascular|cardiac|heart|stroke|mortality|cvd|ascvd)\s+risk\b/i, - // Named risk engines are fabrications on any surface — the server runs - // none. `score2?` (the `2` optional) was the #587 bug: it matched bare - // "score" too, so every mention of this app's OWN computed scores - // (Sleep Score, Readiness Score, Health Score, …) tripped the named- - // engine bank. SCORE2 is a specific clinical risk calculator (like - // Framingham/ASCVD/QRISK) — only the literal name is a fabrication - // signal; the bare word "score" carries none on its own. - /\b(?:framingham|ascvd|score2|qrisk)\b/i, + // (1) digit percent adjacent to a risk noun, either order + new RegExp(`\\b\\d{1,3}\\s*%\\s+${RISK_NOUN_EN}\\b`, "i"), + new RegExp( + `\\b${RISK_NOUN_EN}\\s+(?:of|is|at|around|about|near|sits?\\s+at|would\\s+be)\\s+(?:about\\s+|roughly\\s+|approximately\\s+|around\\s+|~)?\\d{1,3}\\s*%`, + "i", + ), + // (2) spelled-out percent as a risk figure, either order (D4b) + new RegExp(`\\b${RISK_NOUN_EN}\\b[^.?!]{0,40}\\b${PCT_WORD_EN}\\b`, "i"), + new RegExp(`\\b${PCT_WORD_EN}\\b[^.?!]{0,40}\\b${RISK_NOUN_EN}\\b`, "i"), + // (3) 10-year horizon phrase + a qualifying number, either order (M4) + new RegExp(`\\b${HORIZON_EN}\\b[^.?!]{0,40}${QUAL_NUM_EN}`, "i"), + new RegExp(`${QUAL_NUM_EN}[^.?!]{0,40}\\b${HORIZON_EN}\\b`, "i"), + // (4) named engine + a qualifying number, either order + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_EN}`, "i"), + new RegExp(`${QUAL_NUM_EN}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), + // (5) engine / horizon + a categorical RESULT assertion, numberless (D4) + new RegExp( + `\\b(?:${ENGINE}|${HORIZON_EN})\\b[^.?!]{0,60}${RESULT_VERB_EN}`, + "i", + ), + new RegExp( + `\\b(?:${ENGINE}|${HORIZON_EN})\\b[^.?!]{0,60}${RISK_BAND_EN}`, + "i", + ), + // (6) engine / horizon + a categorical risk-level verdict, numberless + new RegExp( + `\\b(?:${ENGINE}|${HORIZON_EN})\\b[^.?!]{0,40}${RISK_VERDICT_EN}`, + "i", + ), ], de: [ - /\brisiko\s+(?:von|bei|liegt\s+bei)\s+(?:etwa\s+|ungefähr\s+|~)?\d{1,3}\s*%/i, - /\b\d{1,3}\s*%\s+(?:risiko|wahrscheinlichkeit)\b/i, - /\b(?:10[- ]jahres|zehn[- ]jahres|lebenszeit)[- ]?(?:risiko)\b/i, + new RegExp( + `\\brisiko\\s+(?:von|bei|liegt\\s+bei)\\s+(?:etwa\\s+|ungefähr\\s+|~)?\\d{1,3}\\s*%`, + "i", + ), + new RegExp(`\\b\\d{1,3}\\s*%\\s+${RISK_NOUN_DE}\\b`, "i"), + // spelled-out percent as a risk figure, either order + new RegExp(`\\b${RISK_NOUN_DE}\\b[^.?!]{0,40}\\b${PCT_WORD_DE}\\b`, "i"), + new RegExp(`\\b${PCT_WORD_DE}\\b[^.?!]{0,40}\\b${RISK_NOUN_DE}\\b`, "i"), + // 10-year horizon + a qualifying number, either order + new RegExp(`\\b${HORIZON_DE}\\b[^.?!]{0,40}${QUAL_NUM_DE}`, "i"), + new RegExp(`${QUAL_NUM_DE}[^.?!]{0,40}\\b${HORIZON_DE}\\b`, "i"), + // named engine (same literals) + qualifying number or categorical result + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_DE}`, "i"), + new RegExp(`${QUAL_NUM_DE}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), + new RegExp( + `\\b(?:${ENGINE}|${HORIZON_DE})\\b[^.?!]{0,60}${RESULT_VERB_DE}`, + "i", + ), + new RegExp( + `\\b(?:${ENGINE}|${HORIZON_DE})\\b[^.?!]{0,60}${RISK_BAND_DE}`, + "i", + ), + new RegExp( + `\\b(?:${ENGINE}|${HORIZON_DE})\\b[^.?!]{0,40}${RISK_VERDICT_DE}`, + "i", + ), ], fr: [ /\brisque\s+(?:est\s+)?(?:de\s+|d['’])?(?:environ\s+|~)?\d{1,3}\s*%/i, @@ -358,6 +452,64 @@ export const INSIGHTS_CONTRACTS: readonly OutboundContract[] = [ "causal", ]; +/** + * Continuation-exclusion for the dose bank (v1.32.7 — D7). A sentence that + * matches a dose pattern is EXEMPT only when it is anchored to a maintenance + * object ("keep taking … as prescribed") AND carries no change stem. So + * "you should keep taking your prescribed 7.5 mg" passes, while + * "you should keep in mind that trying 5 mg" (no maintenance anchor) and + * "you should continue tapering to 2.4 mg" (a change stem present) stay + * blocked. The direction is exclusion-of-continuation, never + * requirement-of-change — "you should try 5 mg" must keep blocking. + * + * Accepted residual FP, failing safe: "I'd recommend discussing the 2.4 mg + * step with your doctor" stays blocked ("step" is a change stem). + */ +const DOSE_CONTINUATION: Record = { + en: /\b(?:keep\s+taking|keep\s+on\s+taking|continue\s+(?:taking|with|on)|stay(?:ing)?\s+(?:on|at)|remain(?:ing)?\s+on|as\s+prescribed|as\s+directed)\b/i, + de: /\b(?:weiterhin|weiter\s+(?:einnehmen|nehmen)|nimm\s+weiter|beibehalten|behalte\s+bei|wie\s+(?:verordnet|verschrieben|besprochen)|bleib(?:e|st)?\s+bei)\b/i, + fr: /\b(?:continuez?\s+(?:à|de|le)|gardez|comme\s+prescrit|tel\s+que\s+prescrit)\b/i, + es: /\b(?:siga\s+(?:tomando|con)|continúe|mantenga|según\s+lo\s+prescrito)\b/i, + it: /\b(?:continui\s+(?:a|con|il)|mantenga|come\s+prescritto)\b/i, + pl: /\b(?:kontynuuj|przyjmuj\s+dalej|zgodnie\s+z\s+zaleceniem|pozosta[ńn]\s+przy)\b/i, +}; + +const DOSE_CHANGE_STEM: Record = { + en: /\b(?:increas|reduc|taper|titrat|step\s+(?:up|down)|lower|raise|halv|doubl|skip|bump|ramp)\w*/i, + de: /\b(?:erhöh|steiger|reduzier|senk|verringer|halbier|verdoppel|absetz|auslass|hochsetz|runtersetz|titrier)\w*/i, + fr: /\b(?:augment|réduis|réduir|diminu|baiss|abaiss|doubl|arrêt|saut)\w*/i, + es: /\b(?:aument|reduzc|reduc|baj|disminu|dobl|omit|salt)\w*/i, + it: /\b(?:aument|riduc|ridur|abbass|diminu|raddoppi|salt|dimezz)\w*/i, + pl: /\b(?:zwiększ|zmniejsz|obniż|podnie[śs]|podwyższ|zreduk|pomi[ńn]|opu[śs][ćc])\w*/i, +}; + +/** Sentence-level split — the dose exemption must be scoped to one sentence. */ +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 { + const bank = BANKS.dose; + const patterns = locale === "en" ? bank.en : [...bank[locale], ...bank.en]; + const continuation = + locale === "en" + ? [DOSE_CONTINUATION.en] + : [DOSE_CONTINUATION[locale], DOSE_CONTINUATION.en]; + const changeStem = + locale === "en" + ? [DOSE_CHANGE_STEM.en] + : [DOSE_CHANGE_STEM[locale], DOSE_CHANGE_STEM.en]; + for (const sentence of splitSentences(subject)) { + if (!patterns.some((p) => p.test(sentence))) continue; + const exempt = + continuation.some((p) => p.test(sentence)) && + !changeStem.some((p) => p.test(sentence)); + if (!exempt) return true; + } + return false; +} + /** * Screen one assembled model output. * @@ -375,6 +527,14 @@ export function screenModelOutput( if (subject.trim().length === 0) return { block: false, reason: null }; for (const contract of contracts) { + 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)) { + return { block: true, reason: REASON_FOR_CONTRACT.dose }; + } + continue; + } const bank = BANKS[contract]; // The reader's bank plus EN — a provider often answers in English // regardless of the locale directive, and EN carries the proven shapes. diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 6e15f8b75..6e08b38c9 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -449,6 +449,7 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${ const unverified = findUnverifiedCoachNumbers( replyText, groundingSources, + locale, ); if (unverified.length > 0) { const { prose, stripped } = stripUnverifiedNumbers(