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:
Marc-André Bombeck authored and GitHub committed 2026-07-23 20:40:32 +02:00
commit f1ef021f6d
11 files changed
+1373 -169

No files matched your search

+29
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.32.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");
});
});
+1
View File
@@ -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);
});
});
+456 -144
View File
@@ -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) ───────────────────────────────────────
+176 -16
View File
@@ -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.
+1
View File
@@ -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(