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:
Marc-André Bombeck authored and GitHub committed 2026-07-24 15:46:19 +02:00
commit df4425babf
12 files changed
+260 -13

No files matched your search

+22
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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/*`
+10
View File
@@ -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 =
+25 -1
View File
@@ -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();
+6 -1
View File
@@ -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.");
});
});
+9 -1
View File
@@ -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(/^["“”'`]+/, "")
+9
View File
@@ -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(/["“”'`]+$/, "")