mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
17 files changed
+1535
-97
No files matched your search
@@ -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,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
@@ -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
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in new issue
Block a user