feat(insights): compactSections drops zero-row blocks before serialization

Walk the features payload and the Coach snapshot through a shared
compactSections helper before JSON.stringify so the prompt never
carries labelled-empty keys ("sleep": [], "medications": {}, etc.).
Empty arrays, empty plain objects, and undefined values are dropped;
null is preserved (it signals "unknown", which the model handles
differently than "missing block").
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 12:31:15 +02:00
1 parent 9feca9e015
commit 6884995995
4 files changed
+163 -2

No files matched your search

+10 -1
View File
@@ -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<string, unknown>,
);
const featuresJson = JSON.stringify(compactFeatures, null, 2);
if (payloadDowngraded) {
annotate({
meta: {
+9 -1
View File
@@ -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),
@@ -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<string, unknown>;
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: "" });
});
});
+70
View File
@@ -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<string, unknown>;
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<T extends PlainObject>(
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;
}