diff --git a/CHANGELOG.md b/CHANGELOG.md index cde53d9cd..bf89ffce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d7381d5b8..12a39b1c2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index b5c8003a3..ce6797dda 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index e0c6020b1..c62403b05 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts index a53e55b7c..1b1f19e6a 100644 --- a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts @@ -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([]); }); diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index 85a378119..dd3dd144d 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -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(`(? { 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.", diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts index a4d71179b..fcb3c7a79 100644 --- a/src/lib/ai/safety/outbound-screen.ts +++ b/src/lib/ai/safety/outbound-screen.ts @@ -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 = { en: [ @@ -278,6 +289,11 @@ const RISK_PATTERNS: Record = { `\\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 = { `\\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,