fix(insights): screen the medication-adherence card and background lines uniformly

Route the medication-compliance status card through finalizeStatusSummary so
it enforces the same dose / risk / causal outbound contracts its seven sibling
status cards do, not only the unparseable-envelope withhold it already had. A
dose-change imperative or a fabricated figure is now withheld to the controlled
fallback on this surface too.

Add the status cards' structured-envelope detection to the two background hero
lines (arrival reaction, coach nudge): a brace-led or fenced JSON reply, or a
truncated envelope under the char cap, now resolves to the deterministic lead
rather than rendering raw.

Fold in the residuals from the same sweep: strip a truncated SUGGEST-ACTION
marker from the Coach prose tail, drop an unreachable zero-rate branch in the
adherence fold, and document why the internal conversation summary stays off
the render-boundary screen.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 14:32:21 +02:00
1 parent 1d3963bebf
commit 0baffe04cd
6 files changed
+73 -10

No files matched your search

+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);
+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",
+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(/["“”'`]+$/, "")