diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f14a149a..89172e489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 61272e806..0ddcc2d0a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index 394273a58..6da91b935 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 555646c48..2aede105f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/lib/ai/coach/conversation-summary.ts b/src/lib/ai/coach/conversation-summary.ts index 10ea0620f..074fc7c9d 100644 --- a/src/lib/ai/coach/conversation-summary.ts +++ b/src/lib/ai/coach/conversation-summary.ts @@ -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 = diff --git a/src/lib/ai/coach/suggest-action.ts b/src/lib/ai/coach/suggest-action.ts index e466b55c0..cc55d8f9d 100644 --- a/src/lib/ai/coach/suggest-action.ts +++ b/src/lib/ai/coach/suggest-action.ts @@ -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); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 61aea6cd8..1e3a850b0 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -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(); diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 50791e585..e294ed998 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -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, diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 5fc2ba088..f7528dc2e 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -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", diff --git a/src/lib/jobs/__tests__/hero-line-json-envelope.test.ts b/src/lib/jobs/__tests__/hero-line-json-envelope.test.ts new file mode 100644 index 000000000..1204fc953 --- /dev/null +++ b/src/lib/jobs/__tests__/hero-line-json-envelope.test.ts @@ -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."); + }); +}); diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index f5c6e82b0..756ca1e02 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -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(/^["“”'`]+/, "") diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index b0bca6138..413c8fcc0 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -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(/["“”'`]+$/, "")