mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
9feca9e015
commit
6884995995
4 files changed
+163
-2
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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: "" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user