mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
merge: QA fix mental-health into release/v1.25.0
This commit is contained in:
commit
8e4c107a18
19 files changed
+606
-104
No files matched your search
@@ -86,6 +86,7 @@ function moduleMap(
|
||||
environment: true,
|
||||
mcp: true,
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
@@ -1515,6 +1515,16 @@ const MEASUREMENT_TYPE_MODULE: Record<string, ModuleKey> = {
|
||||
RECOVERY_SCORE: "recovery",
|
||||
STRESS_SCORE: "recovery",
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
// v1.25.0 — the PHQ-9 / GAD-7 screener TOTALS ride the doctor-report / FHIR
|
||||
// export only when the opt-in mental-health module is on (default OFF →
|
||||
// excluded by default, privacy-by-default). Item-level answers are never a
|
||||
// Measurement, so they can never leak through this path regardless. The total
|
||||
// is the intended clinical artefact (total + band per clinical-instruments.md
|
||||
// §4); gating it on the module keeps the export consistent with how mood /
|
||||
// sleep / glucose are gated and avoids exporting a screener the account never
|
||||
// opted into.
|
||||
PHQ9_SCORE: "mentalHealth",
|
||||
GAD7_SCORE: "mentalHealth",
|
||||
};
|
||||
|
||||
function filterMeasurementKeys<T>(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
scoreTotal,
|
||||
severityBand,
|
||||
isSafetyFlagged,
|
||||
} from "../instruments";
|
||||
|
||||
/**
|
||||
* v1.25.0 — pin the highest-consequence numbers in the release: the PHQ-9 /
|
||||
* GAD-7 totals, the validated severity-band edges (5 / 10 / 15 / 20 and
|
||||
* 5 / 10 / 15), the ≥10 action threshold, and the item-9 ANY-non-zero safety
|
||||
* flag. A future off-by-one on any band edge or the safety predicate must fail
|
||||
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7), mirrored in
|
||||
* `.planning/research/v125/clinical-instruments.md`.
|
||||
*/
|
||||
describe("mental-health instruments", () => {
|
||||
describe("scoreTotal", () => {
|
||||
it("sums the item answers", () => {
|
||||
expect(scoreTotal([0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
|
||||
expect(scoreTotal([0, 0, 0, 0, 0, 0, 0])).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the max when every item is 3", () => {
|
||||
expect(scoreTotal(Array(9).fill(3))).toBe(27); // PHQ-9 max
|
||||
expect(scoreTotal(Array(7).fill(3))).toBe(21); // GAD-7 max
|
||||
});
|
||||
});
|
||||
|
||||
describe("severityBand — PHQ-9 edges (0–4 / 5–9 / 10–14 / 15–19 / 20–27)", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, "minimal"],
|
||||
[4, "minimal"],
|
||||
[5, "mild"],
|
||||
[9, "mild"],
|
||||
[10, "moderate"],
|
||||
[14, "moderate"],
|
||||
[15, "modSevere"],
|
||||
[19, "modSevere"],
|
||||
[20, "severe"],
|
||||
[27, "severe"],
|
||||
];
|
||||
it.each(cases)("PHQ9 total %i → %s", (total, band) => {
|
||||
expect(severityBand("PHQ9", total)).toBe(band);
|
||||
});
|
||||
});
|
||||
|
||||
describe("severityBand — GAD-7 edges (0–4 / 5–9 / 10–14 / 15–21)", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, "minimal"],
|
||||
[4, "minimal"],
|
||||
[5, "mild"],
|
||||
[9, "mild"],
|
||||
[10, "moderate"],
|
||||
[14, "moderate"],
|
||||
[15, "severe"],
|
||||
[21, "severe"],
|
||||
];
|
||||
it.each(cases)("GAD7 total %i → %s", (total, band) => {
|
||||
expect(severityBand("GAD7", total)).toBe(band);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action threshold is ≥10 for both instruments", () => {
|
||||
it("PHQ-9", () => {
|
||||
expect(INSTRUMENTS.PHQ9.actionThreshold).toBe(10);
|
||||
});
|
||||
it("GAD-7", () => {
|
||||
expect(INSTRUMENTS.GAD7.actionThreshold).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSafetyFlagged — PHQ-9 item 9 (index 8), ANY non-zero", () => {
|
||||
it("flags when item 9 = 1, 2, or 3 regardless of total", () => {
|
||||
for (const answer of [1, 2, 3]) {
|
||||
const items = [0, 0, 0, 0, 0, 0, 0, 0, answer];
|
||||
expect(isSafetyFlagged("PHQ9", items)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not flag when item 9 = 0 even with a high total", () => {
|
||||
// Every other item maxed (24), item 9 = 0 → severe band, no flag.
|
||||
const items = [3, 3, 3, 3, 3, 3, 3, 3, 0];
|
||||
expect(severityBand("PHQ9", scoreTotal(items))).toBe("severe");
|
||||
expect(isSafetyFlagged("PHQ9", items)).toBe(false);
|
||||
});
|
||||
|
||||
it("never flags GAD-7 (no safety item)", () => {
|
||||
expect(isSafetyFlagged("GAD7", [3, 3, 3, 3, 3, 3, 3])).toBe(false);
|
||||
expect(INSTRUMENTS.GAD7.safetyItemIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,10 @@
|
||||
* for PHQ-9; Spitzer 2006 for GAD-7). This module owns the canonical NUMERIC
|
||||
* contract; the human wording lives in `messages/*.json` under
|
||||
* `mentalHealth.items.*` (officially-validated translations per locale, English
|
||||
* fallback otherwise — re-wording invalidates the score).
|
||||
* fallback otherwise — re-wording invalidates the score). The de/es/fr/it/pl
|
||||
* item wordings are the public-domain Pfizer/PRIME-MD validated localized
|
||||
* versions distributed via the MAPI Research Trust (de: Löwe/Gräfe et al.); they
|
||||
* are NOT machine-translated. See `.planning/research/v125/clinical-instruments.md`.
|
||||
*
|
||||
* PHQ-9 item 9 (index 8) is the passive self-harm ideation screen and is the
|
||||
* safety-critical field (see `crisis-resources.ts` + the API handler): any
|
||||
|
||||
@@ -352,12 +352,19 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => {
|
||||
});
|
||||
|
||||
describe("registry — opt-in marker", () => {
|
||||
// The opt-in modules ship dark because they open an egress/attack surface:
|
||||
// `mcp` (remote external-assistant endpoint), `environment` (outbound weather
|
||||
// fetch tied to a coarse location), and `inboundDocuments` (sends an uploaded
|
||||
// clinical document to the OCR/vision provider). Every other module is the
|
||||
// default-on disabled-allowlist.
|
||||
const OPT_IN_KEYS = new Set(["mcp", "environment", "inboundDocuments"]);
|
||||
// The opt-in modules ship dark because they open an egress/attack surface or
|
||||
// are highly sensitive: `mcp` (remote external-assistant endpoint),
|
||||
// `environment` (outbound weather fetch tied to a coarse location),
|
||||
// `inboundDocuments` (sends an uploaded clinical document to the OCR/vision
|
||||
// provider), and `mentalHealth` (a depression / anxiety self-assessment — at
|
||||
// least as sensitive as mood, surfaced only on explicit opt-in). Every other
|
||||
// module is the default-on disabled-allowlist.
|
||||
const OPT_IN_KEYS = new Set([
|
||||
"mcp",
|
||||
"environment",
|
||||
"inboundDocuments",
|
||||
"mentalHealth",
|
||||
]);
|
||||
|
||||
it("marks only the egress-surface modules opt-in (every other is default-on)", () => {
|
||||
for (const key of MODULE_KEYS) {
|
||||
|
||||
@@ -136,6 +136,14 @@ export const MODULE_KEYS = [
|
||||
// `optIn` marker on its registry entry inverts the per-user default;
|
||||
// everything else reuses the existing module machinery unchanged.
|
||||
"inboundDocuments",
|
||||
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood
|
||||
// tracking. OPT-IN (off by default): a depression / anxiety self-assessment
|
||||
// is at least as sensitive as mood, so it ships dark and the user turns it on
|
||||
// deliberately. The `optIn` marker inverts the per-user default; with it off
|
||||
// the `/insights/mental-wellbeing` surface is hidden from nav and the
|
||||
// assessment routes refuse server-side. Item answers are always encrypted and
|
||||
// always excluded from the AI Coach / MCP regardless of this toggle.
|
||||
"mentalHealth",
|
||||
] as const;
|
||||
|
||||
export type ModuleKey = (typeof MODULE_KEYS)[number];
|
||||
@@ -298,6 +306,18 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
// configured OCR/vision provider, so the user turns it on deliberately.
|
||||
optIn: true,
|
||||
},
|
||||
mentalHealth: {
|
||||
key: "mentalHealth",
|
||||
labelKey: "modules.mentalHealth.label",
|
||||
descriptionKey: "modules.mentalHealth.description",
|
||||
category: "tracking",
|
||||
// Off by default: a depression / anxiety screener is highly sensitive, so
|
||||
// the surface ships dark and the user opts in deliberately. Turning it on
|
||||
// reveals the `/insights/mental-wellbeing` check-in and lets the screener
|
||||
// total ride the doctor-report export; item answers stay encrypted and
|
||||
// off the AI Coach / MCP regardless.
|
||||
optIn: true,
|
||||
},
|
||||
});
|
||||
|
||||
const MODULE_KEY_SET: ReadonlySet<string> = new Set(MODULE_KEYS);
|
||||
|
||||
Reference in new issue
Block a user