chore(release): v1.32.7 — Coach Guard I (typed grounding + narrowed risk/dose banks)

This commit is contained in:
Marc-André Bombeck committed 2026-07-23 20:25:08 +02:00
1 parent a69d0493f8
commit 9d584806c7
8 files changed
+82 -26

No files matched your search

@@ -297,7 +297,10 @@ describe("G1 — normal-form reconciler grounded-passes", () => {
];
// 444 min ⇔ 7.4 h.
expect(
findUnverifiedCoachNumbers("You averaged about 7.4 hours asleep.", payload),
findUnverifiedCoachNumbers(
"You averaged about 7.4 hours asleep.",
payload,
),
).toEqual([]);
});
@@ -306,7 +309,11 @@ describe("G1 — normal-form reconciler grounded-passes", () => {
{ metric: "steps", section: { aggregate: { latest: 10000 } } },
];
expect(
findUnverifiedCoachNumbers("You hit 10,000 steps yesterday.", payload, "en"),
findUnverifiedCoachNumbers(
"You hit 10,000 steps yesterday.",
payload,
"en",
),
).toEqual([]);
});
+11 -21
View File
@@ -83,13 +83,7 @@ export interface UnverifiedCoachNumber {
* precision, never widens.
* ────────────────────────────────────────────────────────────────────────── */
type NumKind =
| "mass"
| "pressure"
| "pulse"
| "percent"
| "duration"
| "count"
| "glucose";
"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 {
@@ -371,10 +365,7 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] {
? 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 => {
const runPass = (re: RegExp, handle: (m: RegExpExecArray) => void): void => {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(prose)) !== null) {
@@ -472,15 +463,11 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] {
// (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]) });
},
);
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) => {
@@ -683,7 +670,10 @@ export function stripUnverifiedNumbers(
// 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, "\\$&");
const bounded = new RegExp(`(?<![\\d.,+-])${escaped}(?![\\d.,])`);
// 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;
@@ -202,6 +202,16 @@ describe("screenModelOutput — risk narrowing (D1/D4/M4)", () => {
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.",
+20
View File
@@ -241,6 +241,14 @@ 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`;
@@ -251,6 +259,9 @@ 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: [
@@ -278,6 +289,11 @@ const RISK_PATTERNS: Record<Locale, readonly 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: [
new RegExp(
@@ -302,6 +318,10 @@ const RISK_PATTERNS: Record<Locale, readonly 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,