From cf37f833e81e4f01367c3ea43c3bea28e1a19148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 16:44:50 +0200 Subject: [PATCH] fix(insights): keep the briefing blocks of a reply that validates The briefing prompt asks for dailyBriefing, trendAnnotations and storyboardAnnotations beside the insight fields, and the briefing card, the dashboard snapshot and the today read consume them. insightResultSchema was a strict object, so it dropped exactly those blocks from a reply that validated, while a reply that failed validation kept them. It is a loose object now: the named fields are checked and the rest of the reply stays. --- src/lib/ai/types.ts | 11 +++++- .../__tests__/parse-briefing-reply.test.ts | 37 +++++++++++++++++++ .../insights/briefing-request/completion.ts | 4 +- src/lib/insights/comprehensive-generate.ts | 4 +- ...sights-generate.P11-strict-reply.json.snap | 26 +++++++++++++ 5 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/lib/insights/briefing-request/__tests__/parse-briefing-reply.test.ts diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index bf4b47fc2..4dcbdc1bf 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -75,7 +75,16 @@ export const insightRecommendationSchema = z.union([ }), ]); -export const insightResultSchema = z.object({ +/** + * The fields every insight reply must carry. Loose on purpose: the briefing + * prompt also asks for `dailyBriefing`, `trendAnnotations` and + * `storyboardAnnotations`, each read downstream (the briefing card, the + * dashboard snapshot, the today read), and a strict object dropped exactly + * those from a reply that validated, while a reply that did not validate + * kept them. Validation checks these fields and leaves the rest of the reply + * as it came. + */ +export const insightResultSchema = z.looseObject({ insightType: z.string().optional(), summary: z.string(), classification: z.enum([ diff --git a/src/lib/insights/briefing-request/__tests__/parse-briefing-reply.test.ts b/src/lib/insights/briefing-request/__tests__/parse-briefing-reply.test.ts new file mode 100644 index 000000000..68de24fa8 --- /dev/null +++ b/src/lib/insights/briefing-request/__tests__/parse-briefing-reply.test.ts @@ -0,0 +1,37 @@ +/** + * A reply that fits the insight schema keeps the blocks the prompt asks for + * beside it. The strict object used to drop `dailyBriefing` from exactly the + * replies that validated. + */ +import { describe, expect, it } from "vitest"; + +import { parseBriefingReply } from "../grounding"; + +const VALID = { + summary: "Steady week.", + classification: "gut", + findings: [], + correlations: [], + recommendations: [], + dataQuality: { coverage: "good", gaps: [], confidence: "hoch" }, + disclaimer: "Consult your doctor.", +}; + +const BLOCKS = { + dailyBriefing: { paragraph: "Readings held steady.", signalsOfDay: [] }, + trendAnnotations: [{ metric: "weight", note: "flat" }], + storyboardAnnotations: [], +}; + +describe("parseBriefingReply", () => { + it("keeps the briefing blocks of a reply that validates", () => { + const parsed = parseBriefingReply(JSON.stringify({ ...VALID, ...BLOCKS })); + expect(parsed).toMatchObject(BLOCKS); + expect(parsed).toMatchObject({ summary: "Steady week." }); + }); + + it("keeps a reply that does not validate as it was parsed", () => { + const loose = { summary: "No classification.", ...BLOCKS }; + expect(parseBriefingReply(JSON.stringify(loose))).toEqual(loose); + }); +}); diff --git a/src/lib/insights/briefing-request/completion.ts b/src/lib/insights/briefing-request/completion.ts index a63f86804..923c29720 100644 --- a/src/lib/insights/briefing-request/completion.ts +++ b/src/lib/insights/briefing-request/completion.ts @@ -45,8 +45,8 @@ export function runBriefingCall( // `getInsightsSystemPrompt` returned the old `{changed, stable, drivers, // …}` shape and never asked the model for dailyBriefing, so the hero // strip's "Re-run analysis" button kept producing a payload with no - // briefing block. The reply tolerates the strict shape via - // `insightResultSchema.safeParse`'s soft fallback to the parsed object. + // briefing block. `insightResultSchema` is loose, so a reply that + // validates keeps these blocks, and one that does not is kept as parsed. systemPrompt: buildSystemPromptWithReferences( ctx.locale, prompt.referenceMetrics, diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 137f488aa..8ae3feb59 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -342,8 +342,8 @@ async function rerollBriefingParagraph(args: { /** * Parse + (best-effort) validate a comprehensive provider reply. Returns the - * parsed payload (validated when it matches `insightResultSchema`, else the - * raw object so the richer optional blocks survive), or null when the reply + * parsed payload (validated when it matches `insightResultSchema`, which + * keeps the richer optional blocks, else the raw object), or null when the reply * is not valid JSON even after fence-stripping. Null signals the caller to * run its one corrective retry. */ diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap index 646d8ce72..5a2a4783c 100644 --- a/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap @@ -14,6 +14,19 @@ "confidence": "hoch" }, "disclaimer": "Consult your doctor.", + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, "briefingGeneratedAt": "anchor+0d+12:00:00" }, "cached": false, @@ -222,6 +235,19 @@ "confidence": "hoch" }, "disclaimer": "Consult your doctor.", + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, "briefingGeneratedAt": "anchor+0d+12:00:00" }, "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a",