mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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
@@ -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