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

+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/*`
@@ -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,