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",