Merge pull request #605 from MBombeck/release/v1.32.9

release/v1.32.9 — Coach Guard II: grounding ledger, action ladder, six-locale risk screen
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-23 23:20:16 +02:00
commit 78e6d41e23
17 files changed
+1535 -97

No files matched your search

+26
View File
@@ -2,6 +2,32 @@
## [Unreleased]
## [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.
## [1.32.8] — 2026-07-23
Health records written through the app now carry where they came from, and a
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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: <T extends (...args: unknown[]) => 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<typeof import("@/lib/ai/coach/types")>(
"@/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<unknown> }));
vi.mock("@/lib/sse/create-stream", () => ({
createSseStream: (
producer: (c: {
signal: { aborted: boolean };
enqueue: () => void;
}) => void | Promise<void>,
) => {
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<Response>;
function chatReq(body: Record<string, unknown>): 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<typeof vi.fn>).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]");
});
});
+86 -23
View File
@@ -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<Response> {
// ── 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<Response> {
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<Response> {
// 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<Response> {
// 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<Response> {
// (`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<Response> {
})),
}
: {}),
// 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
@@ -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([]);
});
});
@@ -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<LedgerSource, { input: object; value: number }> = {
"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);
}
});
});
+292 -58
View File
@@ -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<number>,
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<number>): 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<Locale, readonly RegExp[]> = {
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<LedgerEntry>,
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));
+7 -7
View File
@@ -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",
},
],
+176
View File
@@ -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<unknown>;
/** 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<number>;
/** Prior USER message bodies (numbers the user themselves stated). */
priorUserMessages?: ReadonlyArray<string>;
/** Coach-memory free-text lines (goals, facts, plans, reminders). */
memoryTexts?: ReadonlyArray<string>;
/** The user's active medication doses (numeric magnitudes). */
scheduleDoses?: ReadonlyArray<number>;
/** 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<LedgerSource> = new Set<LedgerSource>([
"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<LedgerEntry>,
): number[] {
const seen = new Set<number>();
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];
}
+10 -1
View File
@@ -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,
+17 -1
View File
@@ -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;
}
+10
View File
@@ -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<number>;
}
/**
@@ -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<string, string> = {
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);
});
});
+131 -4
View File
@@ -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<Locale, readonly RegExp[]> = {
en: [
// (1) digit percent adjacent to a risk noun, either order
@@ -327,21 +365,53 @@ const RISK_PATTERNS: Record<Locale, readonly RegExp[]> = {
/\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;
+46
View File
@@ -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<number[]> {
const rows = await prisma.medication.findMany({
where: { userId, active: true },
select: { dose: true },
});
const values = new Set<number>();
for (const row of rows) {
const value = parseDoseValue(row.dose);
if (value !== null) values.add(value);
}
return [...values];
}