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