mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #622 from MBombeck/release/v1.32.20
release/v1.32.20 — screen the medication-adherence card and background lines like every other insight
This commit is contained in:
12 files changed
+260
-13
No files matched your search
@@ -2,6 +2,28 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.32.20] — 2026-07-24
|
||||
|
||||
The medication-adherence insight now passes through the same safety screen as
|
||||
every other insight card. It was the one card of eight that showed the model's
|
||||
text without running that screen first. It already held back a reply that came
|
||||
back broken or cut off, but a dose-change instruction or an invented figure
|
||||
could still reach it. It now runs the same check the other seven cards run, and
|
||||
anything that trips the check falls back to the plain, data-grounded line the
|
||||
card shows when no model is set up.
|
||||
|
||||
- **The medication-adherence card is screened like the rest.** A reply that
|
||||
reads as a dose change, or that cites a made-up risk figure, is held back and
|
||||
the card shows its plain fallback. A normal grounded summary still shows as
|
||||
before.
|
||||
- **Two background notification lines can no longer print a raw model reply.**
|
||||
The "just in" reaction line and the proactive coach nudge now catch a reply
|
||||
that arrived as structured data or got cut off partway, and use their fixed
|
||||
wording instead of printing it as is.
|
||||
- **Smaller cleanups in the same area.** A stray marker left behind by a
|
||||
cut-off coach reply is now stripped from the text, and an unreachable branch
|
||||
in the adherence figure was removed.
|
||||
|
||||
## [1.32.19] — 2026-07-24
|
||||
|
||||
After you logged a dose, the Today view could keep showing it as still due
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.32.19
|
||||
version: 1.32.20
|
||||
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.19",
|
||||
"version": "1.32.20",
|
||||
"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.19";
|
||||
/* @sw-version-fallback */ "v1.32.20";
|
||||
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/*`
|
||||
|
||||
@@ -205,6 +205,16 @@ export async function refreshConversationSummary(
|
||||
if (trimmed.length === 0) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
// v1.32.20 — deliberately NOT routed through `screenModelOutput`. This
|
||||
// summary is internal memory only: it is stored encrypted and read back
|
||||
// solely as `priorSummary` to prime the NEXT summary/chat prompt
|
||||
// (`persistence.ts` decrypt → `chat/route.ts` priorSummary). It is never
|
||||
// rendered to a user surface. Any dose/risk/causal phrasing it might carry
|
||||
// cannot reach a person here, and the next Coach reply that could echo it is
|
||||
// itself screened (`screenCoachReply` in the chat route) before it ships.
|
||||
// Screening here would only risk withholding a legitimate internal summary
|
||||
// (memory loss) with no user-facing safety gain — so the screen stays on the
|
||||
// rendering boundaries, not this internal-memory write.
|
||||
// Backstop the stored length even if the provider over-ran the prompt's
|
||||
// "short paragraph" instruction.
|
||||
const text =
|
||||
|
||||
@@ -213,11 +213,35 @@ function resolveActionParams(
|
||||
* invalid param, drops the action (`malformed: true`) while still removing the
|
||||
* raw marker from the prose.
|
||||
*/
|
||||
/**
|
||||
* Strip a TRUNCATED (partial) open sentinel left at the tail of the model's
|
||||
* output. A reply cut off mid-marker — e.g. "…worth a look.\n---SUGGEST-ACTIO"
|
||||
* — never contains the full `OPEN_SENTINEL`, so the block strip below misses it
|
||||
* and the fragment renders raw (the #608 malformed-output class). Remove any
|
||||
* trailing run that is a non-empty proper prefix of the open sentinel of at
|
||||
* least `---S`, so a legitimate bare "---" separator is left untouched.
|
||||
*/
|
||||
function stripTruncatedOpenSentinel(prose: string): string {
|
||||
const trimmedEnd = prose.replace(/\s+$/u, "");
|
||||
for (let len = OPEN_SENTINEL.length - 1; len >= 4; len--) {
|
||||
const partial = OPEN_SENTINEL.slice(0, len);
|
||||
if (trimmedEnd.endsWith(partial)) {
|
||||
return trimmedEnd.slice(0, trimmedEnd.length - partial.length).trimEnd();
|
||||
}
|
||||
}
|
||||
return prose;
|
||||
}
|
||||
|
||||
export function parseSuggestAction(raw: string): SuggestActionParseResult {
|
||||
if (!raw) return { prose: "", action: null, malformed: false };
|
||||
|
||||
const openIdx = raw.indexOf(OPEN_SENTINEL);
|
||||
if (openIdx === -1) return { prose: raw, action: null, malformed: false };
|
||||
if (openIdx === -1) {
|
||||
// No full block — but a truncated open marker at the tail must still be
|
||||
// scrubbed so the raw fragment never reaches the user's prose.
|
||||
const prose = stripTruncatedOpenSentinel(raw);
|
||||
return { prose, action: null, malformed: prose !== raw };
|
||||
}
|
||||
|
||||
const before = raw.slice(0, openIdx);
|
||||
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("@/lib/medication-category", () => ({
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
@@ -218,6 +219,100 @@ describe("generateMedicationComplianceStatusForUser — cache-read skips a stub"
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.20)", () => {
|
||||
/**
|
||||
* The medication-adherence card is the highest-leverage surface for the
|
||||
* outbound screen — a dose-change imperative or a fabricated figure lands
|
||||
* exactly here. v1.32.20 routes it through `finalizeStatusSummary`, so it now
|
||||
* WITHHOLDS the same dose / risk / causal violations its seven sibling status
|
||||
* cards do (previously it only withheld unparseable envelopes). WITHHOLD =
|
||||
* serve the deterministic no-key line, persist no model text, `updatedAt`
|
||||
* stays null; the per-medication placeholder rows still render.
|
||||
*/
|
||||
function setupCard(now: Date) {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
medFixture(now),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: now,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("withholds a dose-change imperative instead of rendering the raw prose", async () => {
|
||||
const now = new Date();
|
||||
setupCard(now);
|
||||
stubCompletion('{"summary":"Increase your dose to 10 mg next week."}');
|
||||
|
||||
const result = await generateMedicationComplianceStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(result.summary).toBe(getNoKeyMedicationComplianceStatusText("en"));
|
||||
expect(result.summary).not.toContain("10 mg");
|
||||
expect(result.summary).not.toContain("Increase");
|
||||
expect(result.updatedAt).toBeNull();
|
||||
// The per-medication placeholder rows still surface for the active med.
|
||||
expect(result.medications).toHaveLength(1);
|
||||
// No cache row ever persisted the screened model text.
|
||||
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
|
||||
const details = JSON.parse(
|
||||
(call[0] as { data: { details: string } }).data.details,
|
||||
) as { summary?: string };
|
||||
expect(details.summary ?? "").not.toContain("10 mg");
|
||||
}
|
||||
});
|
||||
|
||||
it("withholds a fabricated risk figure instead of rendering the raw prose", async () => {
|
||||
const now = new Date();
|
||||
setupCard(now);
|
||||
stubCompletion(
|
||||
'{"summary":"Your 10-year cardiovascular risk is about 14%."}',
|
||||
);
|
||||
|
||||
const result = await generateMedicationComplianceStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(result.summary).toBe(getNoKeyMedicationComplianceStatusText("en"));
|
||||
expect(result.summary).not.toContain("14%");
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(result.medications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still renders a clean grounded compliance summary", async () => {
|
||||
const now = new Date();
|
||||
setupCard(now);
|
||||
stubCompletion(
|
||||
'{"summary":"Adherence held steady at 96% across the last 30 days."}',
|
||||
);
|
||||
|
||||
const result = await generateMedicationComplianceStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(result.summary).toBe(
|
||||
"Adherence held steady at 96% across the last 30 days.",
|
||||
);
|
||||
expect(result.updatedAt).not.toBeNull();
|
||||
expect(result.cached).toBe(false);
|
||||
// The clean assessment was persisted (not the negative stub).
|
||||
const persisted = vi
|
||||
.mocked(prisma.auditLog.create)
|
||||
.mock.calls.map(
|
||||
(call) =>
|
||||
JSON.parse(
|
||||
(call[0] as { data: { details: string } }).data.details,
|
||||
) as { summary?: string },
|
||||
)
|
||||
.find((details) => (details.summary ?? "").includes("96%"));
|
||||
expect(persisted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
|
||||
it("strips metric: tokens out of the cached summary", async () => {
|
||||
const now = new Date();
|
||||
|
||||
@@ -284,7 +284,12 @@ export async function prepareGeneralStatusForUser(
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, value]) => ({
|
||||
measuredAt: new Date(`${day}T12:00:00.000Z`),
|
||||
value: value.total > 0 ? round((value.taken / value.total) * 100, 1) : 0,
|
||||
// A map entry is only ever created after `bucket.total += 1`, so
|
||||
// `value.total` is always >= 1 here — the former `: 0` guard branch was
|
||||
// unreachable. v1.32.20 removed it: a real zero-dose day never reaches
|
||||
// this fold, so a synthesised `0` would have been a fabricated rate, not
|
||||
// an honest absence (the #582 silent-wrong-absence class).
|
||||
value: round((value.taken / value.total) * 100, 1),
|
||||
}));
|
||||
const adherenceSeries = applyPayloadBudget(adherenceRecords, {
|
||||
now,
|
||||
|
||||
@@ -30,7 +30,7 @@ import { buildMetricSignal } from "@/lib/insights/metric-signal";
|
||||
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
extractAssessmentSummary,
|
||||
finalizeStatusSummary,
|
||||
normalizeLocale,
|
||||
normalizeSummaryText,
|
||||
round,
|
||||
@@ -577,15 +577,22 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
// does not emit per-medication text. The per-medication cards carry a
|
||||
// placeholder so the UI surfaces a row per active medication; the
|
||||
// overall `summary` is the model-authored assessment.
|
||||
// A malformed / truncated / summary-less structured envelope must never
|
||||
// persist as the day's assessment (the raw-JSON class of bug). WITHHOLD:
|
||||
//
|
||||
// v1.32.20 — route the completion through the shared status chokepoint,
|
||||
// so this card enforces the exact contracts its seven siblings do rather
|
||||
// than only withholding unparseable envelopes. `finalizeStatusSummary`
|
||||
// parses the `{ summary }` envelope, scrubs chart tokens + whitespace,
|
||||
// then SCREENS the prose against `INSIGHTS_CONTRACTS` (dose / risk /
|
||||
// causal). This is the highest-leverage surface for the outbound screen —
|
||||
// a medication card is exactly where a dose-change imperative or a
|
||||
// fabricated figure lands. WITHHOLD on any block or unparseable envelope:
|
||||
// serve the deterministic no-key line, write a short negative stub, and
|
||||
// persist no model text. The per-medication rows still render.
|
||||
const extracted = extractAssessmentSummary(outcome.content);
|
||||
if (extracted.kind === "unparseable") {
|
||||
const finalized = finalizeStatusSummary(outcome.content, locale);
|
||||
if (!finalized.ok) {
|
||||
annotate({
|
||||
action: { name: "insights.status.outbound_blocked" },
|
||||
meta: { cacheAction, reason: "unparseable_output" },
|
||||
meta: { cacheAction, reason: finalized.reason },
|
||||
});
|
||||
returnTimeoutFallback({
|
||||
cacheAction,
|
||||
@@ -602,7 +609,7 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
const summary = normalizeSummaryText(extracted.text);
|
||||
const summary = finalized.text;
|
||||
if (!summary) {
|
||||
throw new Error(
|
||||
"Medication-compliance-status summary was empty after normalization",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The sanitisers are pure, but their modules import the db client at load time.
|
||||
// Stub it so the unit under test never reaches for a connection.
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import { sanitiseReactionLine } from "@/lib/jobs/reaction-line";
|
||||
import { sanitiseAiBody } from "@/lib/jobs/coach-nudge-ai";
|
||||
|
||||
/**
|
||||
* v1.32.20 — both background hero lines (the arrival reaction and the coach
|
||||
* nudge body) now run the status cards' structured-envelope detection
|
||||
* (`extractAssessmentSummary`). A hero line is one plain sentence; a brace-led
|
||||
* or fenced `{"line":"…"}` reply, or a truncated envelope under the char cap,
|
||||
* is a broken contract and must resolve to the deterministic lead/template
|
||||
* rather than render raw JSON. Genuine prose still ships.
|
||||
*/
|
||||
describe("sanitiseReactionLine — JSON-envelope detection", () => {
|
||||
it("rejects a well-formed JSON envelope so the deterministic lead stands", () => {
|
||||
expect(
|
||||
sanitiseReactionLine(
|
||||
'{"line":"Nice work today, your numbers are holding steady."}',
|
||||
"en",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a truncated brace-led envelope", () => {
|
||||
expect(sanitiseReactionLine('{"line":"Nice work today', "en")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a fenced JSON envelope", () => {
|
||||
expect(
|
||||
sanitiseReactionLine('```json\n{"line":"Steady week."}\n```', "en"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still renders a plain-text line verbatim", () => {
|
||||
expect(
|
||||
sanitiseReactionLine(
|
||||
"Nice work today, your numbers are holding steady.",
|
||||
"en",
|
||||
),
|
||||
).toBe("Nice work today, your numbers are holding steady.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitiseAiBody — JSON-envelope detection", () => {
|
||||
it("rejects a well-formed JSON envelope so the template stands", () => {
|
||||
expect(
|
||||
sanitiseAiBody(
|
||||
'{"body":"A gentle check-in on your routine this week."}',
|
||||
"en",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a truncated brace-led envelope", () => {
|
||||
expect(sanitiseAiBody('{"body":"A gentle check-in', "en")).toBeNull();
|
||||
});
|
||||
|
||||
it("still renders a plain-text body verbatim", () => {
|
||||
expect(
|
||||
sanitiseAiBody("A gentle check-in on your routine this week.", "en"),
|
||||
).toBe("A gentle check-in on your routine this week.");
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { screenCoachReply } from "@/lib/ai/coach/outbound-guard";
|
||||
import { extractAssessmentSummary } from "@/lib/insights/status-shared";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/** Per-call upstream timeout — kept inside the spec's ≤8–10 s window. */
|
||||
@@ -138,9 +139,16 @@ Rephrase it warmly and naturally in your own words, following every rule. Return
|
||||
* unusable (empty / over-length / unsafe). Strips wrapping quotes and collapses
|
||||
* whitespace; rejects rather than mid-word-clamping an over-long reply.
|
||||
*/
|
||||
function sanitiseAiBody(raw: string, locale: Locale): string | null {
|
||||
export function sanitiseAiBody(raw: string, locale: Locale): string | null {
|
||||
let text = (raw ?? "").trim();
|
||||
if (!text) return null;
|
||||
// v1.32.20 — the same structured-envelope detection the status cards run
|
||||
// (`extractAssessmentSummary`). A nudge body is one plain sentence; a
|
||||
// brace-led or fenced `{"line":"…"}` / `{"body":"…"}` reply — or a truncated
|
||||
// envelope under the char cap — is a broken contract and must resolve to the
|
||||
// deterministic template rather than render raw JSON. Only genuine prose
|
||||
// (`kind === "prose"`) passes.
|
||||
if (extractAssessmentSummary(text).kind !== "prose") return null;
|
||||
// Strip a single layer of wrapping quotes the model sometimes adds.
|
||||
text = text
|
||||
.replace(/^["“”'`]+/, "")
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { screenCoachReply } from "@/lib/ai/coach/outbound-guard";
|
||||
import { extractAssessmentSummary } from "@/lib/insights/status-shared";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import { singleUserTurn, type CompletionResult } from "@/lib/ai/types";
|
||||
@@ -113,6 +114,14 @@ export function sanitiseReactionLine(
|
||||
): string | null {
|
||||
let text = (raw ?? "").trim();
|
||||
if (!text) return null;
|
||||
// v1.32.20 — the same structured-envelope detection the status cards run
|
||||
// (`extractAssessmentSummary`). A hero line is one plain sentence; a
|
||||
// brace-led or fenced `{"line":"…"}` reply — or a truncated envelope under
|
||||
// the char cap — is a broken contract, not a sentence, and must resolve to
|
||||
// the deterministic lead rather than render raw JSON. Only genuine prose
|
||||
// (`kind === "prose"`) passes; a parsed `{ summary }`/`{ line }` object or an
|
||||
// unparseable brace-led attempt both reject here.
|
||||
if (extractAssessmentSummary(text).kind !== "prose") return null;
|
||||
text = text
|
||||
.replace(/^["“”'`]+/, "")
|
||||
.replace(/["“”'`]+$/, "")
|
||||
|
||||
Reference in new issue
Block a user