diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts index 2ef68d7cd..b0f201352 100644 --- a/src/lib/ai/prompts/weight.ts +++ b/src/lib/ai/prompts/weight.ts @@ -7,6 +7,7 @@ const WEIGHT_SECTION_DE = `METRIK — GEWICHT: - Der Snapshot trägt weight.signal (der fertige Vergleich) + weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages. - Fokus dieser Karte ist der kontinuierliche TREND und das TEMPO über die Zeit (kg/Woche, Plateaus, Meilensteine), nicht die WHO-Band-Einordnung — die trägt die BMI-Karte. Den BMI-Wert nur als Nebenbezug nennen, nie das Band als zentrale Aussage hier. - Wochen-/Monatstrend zählt, nicht der Einzelwert: Führe mit weight.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund. +- Eigenes Ziel: Trägt der Snapshot weight.target, ist das der Zielbereich, den die Person selbst gesetzt hat; weight.target.progress sagt, welche Richtung Fortschritt ist (gaining = zunehmen, losing = abnehmen, holding steady = halten). Beurteile jede Veränderung GEGEN dieses Ziel: Liegt die Person darunter, ist eine Zunahme Fortschritt und nie ein Rückschritt. Die Hinweise zu Tempo und Meilensteinen beim Abnehmen unten gelten nur, wenn Abnehmen die Fortschrittsrichtung ist oder kein Ziel vorliegt. - Plateau: Verändert sich das Mittel über > ~2 Wochen um < 0.5 kg, ist das ein Plateau — bei einem Abnehmenden ehrlich benennen, ohne zu entmutigen. - Tempo: Nachhaltiger Verlust liegt bei ~0.5-1.0 kg/Woche (DGE). Anhaltend > 1 kg/Woche ist eher Wasser/Muskel als Fett — vorsichtig einordnen. - Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen. @@ -18,6 +19,7 @@ const WEIGHT_SECTION_EN = `METRIC — WEIGHT: - The snapshot carries weight.signal (the finished comparison) + weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure. - This card's focus is the continuous TREND and PACE over time (kg/week, plateaus, milestones), not WHO-band placement — the BMI card carries that. Mention the BMI value only as a side reference, never make the band the central message here. - The weekly/monthly trend matters, not the single value: lead from weight.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding. +- Own target: when the snapshot carries weight.target, that is the target band the person set themselves, and weight.target.progress says which way is progress (gaining, losing, or holding steady). Judge every change AGAINST that target: below it, a gain is progress and never a setback or a regression. The loss pace and milestone guidance below applies only when losing is the progress direction or no target is set. - Plateau: if the mean moves < 0.5 kg over > ~2 weeks, that is a plateau — name it honestly for someone who is losing, without discouraging. - Pace: sustainable loss is ~0.5-1.0 kg/week (DGE). Sustained > 1 kg/week is more likely water/muscle than fat — frame cautiously. - Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached. diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 5f715f219..84e85ffb3 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -357,3 +357,43 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", ( expect(parsed.text).not.toContain("metric:"); }); }); + +describe("generateWeightStatusForUser — judged against the stored target (#1006)", () => { + async function snapshotFor(thresholdsJson: unknown) { + const now = new Date(); + const records = Array.from({ length: 40 }, (_, day) => ({ + type: "WEIGHT", + // Rising from 58 toward a 65–70 target over the last forty days. + value: 60 - day * 0.05, + measuredAt: new Date(now.getTime() - day * dayMs), + })); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + thresholdsJson, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + const captured: { userPrompt: string | null } = { userPrompt: null }; + stubCompletion('{"summary":"OK"}', captured); + await generateWeightStatusForUser("user-1", { locale: "en" }); + return JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]); + } + + it("below the target, the model reads gaining as progress", async () => { + const snapshot = await snapshotFor({ WEIGHT: { min: 65, max: 70 } }); + expect(snapshot.weight.target).toMatchObject({ + position: "below", + progress: "gaining", + }); + expect(snapshot.weight.signal.direction).toBe("higher-better"); + }); + + it("without a target, no direction is claimed", async () => { + const snapshot = await snapshotFor(null); + expect(snapshot.weight.target).toBeUndefined(); + expect(snapshot.weight.signal.direction).toBe("target-band"); + }); +}); diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 607e334bb..1bea20fbb 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -10,6 +10,8 @@ import { type PairedPoint, } from "@/lib/analytics/correlations"; import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks"; +import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range"; +import { buildWeightTargetFeature } from "@/lib/targets/weight-trend"; import { formatPreviousContextForPrompt, getPreviousInsightContext, @@ -230,9 +232,24 @@ export async function prepareWeightStatusForUser( // correlation math + the provider call) is skipped. A forced run never // gates — but still computes the fingerprint so the persisted row carries a // current one for the next day's gate. + // v1.39 (#1006) — the person's own weight target decides which way is + // progress. Folded into the input fingerprint so editing the target flips + // the gate even when no weight row moved. + const weightTarget = resolveWeightTargetOverride( + ( + await prisma.user.findUnique({ + where: { id: userId }, + select: { thresholdsJson: true }, + }) + )?.thresholdsJson, + ); const inputHash = await computeStatusInputFingerprint({ userId, types: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + extra: { + weightTargetMin: weightTarget?.min ?? null, + weightTargetMax: weightTarget?.max ?? null, + }, includeMood: true, // v1.18.11 (P6-tighten) — WEIGHT is an FDR discovery OUTCOME channel and // the prompt folds `getRelevantCorrelationsForMetric(userId, "WEIGHT")`, so @@ -444,12 +461,30 @@ export async function prepareWeightStatusForUser( // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline // comparison + normal-swing verdict instead of asking it to derive them. - // Weight has no universal favourable direction or population band, so the - // signal carries the neutral "target-band" framing and no normalRange. + // Weight has no universal favourable direction or population band, so + // without a stored target the signal carries the neutral "target-band" + // framing and no normalRange. v1.39 (#1006) — with one, the direction is + // the person's own: below the band a rise is favourable, above it a fall. + const recentWeights = weightSeries.daily + .filter((bucket) => bucket.dayOffset < 7) + .map((bucket) => bucket.value); + const weightTargetFeature = buildWeightTargetFeature(weightTarget, { + avg7: + recentWeights.length > 0 + ? recentWeights.reduce((sum, value) => sum + value, 0) / + recentWeights.length + : null, + latest: latestWeight?.value ?? null, + }); const weightSignal = buildMetricSignal({ metric: locale === "en" ? "your weight" : "dein Gewicht", unit: "kg", - direction: "target-band", + direction: + weightTargetFeature?.position === "below" + ? "higher-better" + : weightTargetFeature?.position === "above" + ? "lower-better" + : "target-band", graded: weightGraded, newestDaysAgo: newestMeasurementDaysAgo, }); @@ -465,6 +500,7 @@ export async function prepareWeightStatusForUser( }, weight: { ...(weightSignal ? { signal: weightSignal } : {}), + ...(weightTargetFeature ? { target: weightTargetFeature } : {}), summary: summarizeSeries( weightSeries.daily.map((bucket) => ({ value: bucket.value })), ),