diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 297947196..61dcc1e52 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -6,6 +6,7 @@ import { FeaturesPayloadTooLargeError, } from "@/lib/insights/features"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; +import { compactSections } from "@/lib/insights/prompt-compact"; import { detectGlp1Plateau, buildGlp1PlateauPrompt, @@ -300,7 +301,15 @@ export const POST = apiHandler(async (request: NextRequest) => { // BEFORE serialisation so the model never sees the dropped blocks. const excludeList = dbUser?.insightsExcludeMetrics ?? []; features = applyInsightsExcludeFilter(features, excludeList); - const featuresJson = JSON.stringify(features, null, 2); + // v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries + // labelled-empty sections (`"sleep": []`, `"medications": []`, + // etc.). Prevents the model from narrating "there are no + // medications in your data" when the user explicitly excluded the + // block. + const compactFeatures = compactSections( + features as unknown as Record, + ); + const featuresJson = JSON.stringify(compactFeatures, null, 2); if (payloadDowngraded) { annotate({ meta: { diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 9c46c9c5d..bbb739050 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -22,6 +22,7 @@ import { type CoachExcludeMetric, } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { compactSections } from "@/lib/insights/prompt-compact"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import type { CoachProvenance, @@ -817,8 +818,15 @@ async function buildCoachSnapshotImpl( timelineRecentDays: DAILY_TIMELINE_DAYS, }; + // v1.4.36 W3 T4 — compactSections drops any zero-row block before + // serialisation so the prompt never carries a labelled-empty key. + // The snapshot is built conditionally above so most empty paths are + // already skipped, but the helper catches future regressions and + // matches the contract the /insights/generate route applies on its + // side of the prompt. + const compactSnapshot = compactSections(snapshot); return { - snapshotJson: JSON.stringify(snapshot, null, 2), + snapshotJson: JSON.stringify(compactSnapshot, null, 2), provenance: { windows: Array.from(windows), metrics: Array.from(metrics), diff --git a/src/lib/insights/__tests__/prompt-compact.test.ts b/src/lib/insights/__tests__/prompt-compact.test.ts new file mode 100644 index 000000000..4c4ca864b --- /dev/null +++ b/src/lib/insights/__tests__/prompt-compact.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { compactSections } from "../prompt-compact"; + +describe("compactSections", () => { + it("drops empty arrays", () => { + const out = compactSections({ sleep: [], weight: [{ v: 1 }] }); + expect(out).toEqual({ weight: [{ v: 1 }] }); + }); + + it("drops empty plain objects", () => { + const out = compactSections({ medications: {}, weight: { latest: 82 } }); + expect(out).toEqual({ weight: { latest: 82 } }); + }); + + it("drops undefined values", () => { + const out = compactSections({ weight: undefined, bp: { avg: 128 } }); + expect(out).toEqual({ bp: { avg: 128 } }); + }); + + it("preserves null (null is a meaningful 'unknown' marker, not 'empty')", () => { + const out = compactSections({ avg30: null, avg7: 82 }); + expect(out).toEqual({ avg30: null, avg7: 82 }); + }); + + it("preserves nested objects shallow by default (count: 0 stays)", () => { + const out = compactSections({ + coverage: { count: 0, spanDays: 0 }, + bp: { avg30: 128 }, + }); + expect(out.coverage).toEqual({ count: 0, spanDays: 0 }); + }); + + it("deep mode recurses into nested plain objects", () => { + const out = compactSections( + { + bp: { avg30: 128, history: [] }, + ghost: { medications: [] }, + }, + { deep: true }, + ); + expect(out).toEqual({ bp: { avg30: 128 } }); + }); + + it("preserves arrays of objects with content", () => { + const out = compactSections({ + medications: [{ name: "Mounjaro" }], + bp: [], + }); + expect(out).toEqual({ medications: [{ name: "Mounjaro" }] }); + }); + + it("returns a new object — does not mutate the input", () => { + const input = { sleep: [], weight: 82 } as Record; + const out = compactSections(input); + expect(input.sleep).toEqual([]); + expect(out).not.toBe(input); + }); + + it("renders empty input as empty output", () => { + expect(compactSections({})).toEqual({}); + }); + + it("does not drop primitive zeros, false, or empty strings", () => { + // These are legitimate data signals — only structural emptiness + // (empty arrays/objects/undefined) is filtered. + const out = compactSections({ + count: 0, + enabled: false, + label: "", + }); + expect(out).toEqual({ count: 0, enabled: false, label: "" }); + }); +}); diff --git a/src/lib/insights/prompt-compact.ts b/src/lib/insights/prompt-compact.ts new file mode 100644 index 000000000..fa72a274d --- /dev/null +++ b/src/lib/insights/prompt-compact.ts @@ -0,0 +1,70 @@ +/** + * v1.4.36 W3 T4 — `compactSections` helper for the AI prompt body. + * + * The features payload (and the Coach snapshot, transitively) carries + * conditionally-populated blocks: `medications`, `bucketedMeasurements`, + * `historicalComparison`, the per-metric aggregates, etc. Empty blocks + * have two failure modes once they hit the prompt: + * + * 1. The serialised JSON includes a labelled-empty key (`"sleep": []` + * or `"medications": []`) which the model occasionally narrates as + * "there are no medications in your data" even when the user + * explicitly excluded them — wasted prompt budget and a misleading + * framing. + * 2. Some downstream prose composers render the block label without + * checking row count, producing the v1.4.35 "Hier sind die + * Schlafdaten: [keine]" anti-pattern Marc flagged in W3. + * + * `compactSections` walks the object once and drops any property whose + * value is: + * - an empty array + * - an empty object (no own enumerable keys) + * - undefined + * + * It runs a single shallow pass at the top level — that's enough for + * the features + snapshot shapes today. We do NOT recurse into nested + * objects because the legitimate empty values inside e.g. + * `coverage: { count: 0, … }` should stay readable (count 0 IS the + * signal). A future call site that needs deep-compaction can opt in + * via the `deep` flag. + */ + +type PlainObject = Record; + +function isPlainObject(value: unknown): value is PlainObject { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +function isEmpty(value: unknown): boolean { + if (value === undefined) return true; + if (Array.isArray(value)) return value.length === 0; + if (isPlainObject(value)) return Object.keys(value).length === 0; + return false; +} + +/** + * Return a shallow copy of `blocks` with every empty key dropped. + * `null` values are preserved — null carries the semantic "field + * exists but is unknown", which is different from "block doesn't + * exist". Set `deep: true` to recurse into nested plain objects. + */ +export function compactSections( + blocks: T, + options: { deep?: boolean } = {}, +): T { + const out: PlainObject = {}; + for (const [key, value] of Object.entries(blocks)) { + let folded = value; + if (options.deep && isPlainObject(value)) { + folded = compactSections(value, options); + } + if (isEmpty(folded)) continue; + out[key] = folded; + } + return out as T; +}