mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
chore(release): v1.32.7 — Coach Guard I (typed grounding + narrowed risk/dose banks)
This commit is contained in:
1 parent
a69d0493f8
commit
9d584806c7
8 files changed
+82
-26
No files matched your search
@@ -2,6 +2,35 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.32.7] — 2026-07-23
|
||||
|
||||
The Coach output guard now reads numbers the way you actually write them, so a
|
||||
correct figure stops getting marked unverified while an invented one still gets
|
||||
caught.
|
||||
|
||||
- **A number you reformatted is no longer flagged.** The guard recognises a
|
||||
rounded restatement, a delta narrated as a drop, an adherence rate written as
|
||||
a percent, minutes read back as hours, a reformatted date, and a
|
||||
thousands-separated step count, each checked against the figure the server
|
||||
actually gave the Coach. A drifted number, like an average of 128 quoted back
|
||||
as 138, is still caught.
|
||||
- **A blood-pressure claim can no longer borrow a weight number.** Every figure
|
||||
is matched inside its own unit family, and a raw reading never earns the
|
||||
looser rounding reserved for a headline average, so a fabricated average
|
||||
cannot slip through on a nearby sample.
|
||||
- **A correct clinical-risk refusal streams through untouched.** Naming ASCVD or
|
||||
a ten-year risk to explain that a clinician computes it now passes. What still
|
||||
blocks is an asserted figure or a categorical verdict, whether it carries
|
||||
digits ("your risk is about 14%"), spelled-out words ("roughly twelve
|
||||
percent"), or no number at all ("SCORE2 would put you in the high-risk band").
|
||||
- **A dose you already take reads as support.** "Keep taking your 7.5 mg as
|
||||
prescribed" passes, while anything that moves the dose stays blocked.
|
||||
- **A figure removed as unverifiable leaves the sentence intact.** The strip
|
||||
touches only the exact token, so a year like 2023 is never clipped by a
|
||||
flagged 23.
|
||||
|
||||
Refs #587, #591.
|
||||
|
||||
## [1.32.6] — 2026-07-23
|
||||
|
||||
Hardening and data-integrity fixes that were reviewed earlier and are now folded
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.32.6
|
||||
version: 1.32.7
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.32.6",
|
||||
"version": "1.32.7",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.32.6";
|
||||
/* @sw-version-fallback */ "v1.32.7";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user