diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index b979a59d7..17b8a9765 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -9,8 +9,13 @@ import { import { extractFeatures, FeaturesPayloadTooLargeError, + BRIEFING_FEATURE_WINDOW_DAYS, } from "@/lib/insights/features"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; +import { + buildBriefingIllnessCycleContext, + buildBriefingIllnessCyclePrompt, +} from "@/lib/insights/illness-cycle-briefing"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { detectGlp1Plateau, @@ -475,14 +480,18 @@ export const POST = apiHandler(async (request: NextRequest) => { "resting_hr", ]; let payloadHardDowngraded = false; + // v1.18.11 P1 — bound the briefing's bulk feature read to a recent window + // (all-time extremes are sourced separately, see features.ts). The downgrade + // ladder below stays as the safety net for the rare oversize payload. + const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS }; try { - features = await extractFeatures(userId, includeRaw); + features = await extractFeatures(userId, includeRaw, featureWindow); } catch (err) { if (err instanceof FeaturesPayloadTooLargeError) { payloadDowngraded = true; payloadOversizeBytes = err.sizeBytes; try { - features = await extractFeatures(userId, false); + features = await extractFeatures(userId, false, featureWindow); } catch (retryErr) { if (retryErr instanceof FeaturesPayloadTooLargeError) { // Aggregated shape ALSO crossed the ceiling. Drop optional @@ -492,7 +501,11 @@ export const POST = apiHandler(async (request: NextRequest) => { payloadHardDowngraded = true; payloadOversizeBytes = retryErr.sizeBytes; try { - const aggregated = await extractFeatures(userId, false); + const aggregated = await extractFeatures( + userId, + false, + featureWindow, + ); features = applyInsightsExcludeFilter( aggregated, MAX_DOWNGRADE_TOKENS, @@ -592,6 +605,19 @@ export const POST = apiHandler(async (request: NextRequest) => { userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); } + // v1.18.11 P5 — fold illness-episode + cycle state into the briefing so a + // mid-illness or mid-cycle user gets context-aware prose. Reuses the same + // server-authoritative builders the Coach assembles; both are module-gated + // and short-circuit to null (zero token cost) for users without them. + const illnessCycleCtx = await buildBriefingIllnessCycleContext( + userId, + dbUser?.gender ?? null, + await resolveUserTimezone(userId), + ); + if (illnessCycleCtx) { + userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale); + } + // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections // this briefing actually carries, so a normative claim can cite a real // `referenceId` the schema + UI footnote already support. Returns the plain diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 1357a76d2..94e45fa29 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -41,6 +41,11 @@ vi.mock("@/lib/insights/features", () => ({ sizeBytes = 0; }, extractFeatures: (...a: unknown[]) => extractFeatures(...a), + BRIEFING_FEATURE_WINDOW_DAYS: 400, +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null), + buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""), })); vi.mock("@/lib/insights/glp1-plateau", () => ({ detectGlp1Plateau: vi.fn(async () => null), diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 114a50316..318b5b665 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -50,6 +50,11 @@ vi.mock("@/lib/insights/features", () => ({ sizeBytes = 0; }, extractFeatures: (...a: unknown[]) => extractFeatures(...a), + BRIEFING_FEATURE_WINDOW_DAYS: 400, +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null), + buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""), })); // The post-gate prompt assembly reads GLP-1 + about-me context from the // DB; stub both so the changed-hash path stays DB-free in this test. diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index aae976aa0..664094870 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -39,6 +39,11 @@ vi.mock("@/lib/insights/features", () => ({ sizeBytes = 0; }, extractFeatures: (...a: unknown[]) => extractFeatures(...a), + BRIEFING_FEATURE_WINDOW_DAYS: 400, +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null), + buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""), })); vi.mock("@/lib/insights/glp1-plateau", () => ({ detectGlp1Plateau: vi.fn(async () => null), diff --git a/src/lib/insights/__tests__/illness-cycle-briefing.test.ts b/src/lib/insights/__tests__/illness-cycle-briefing.test.ts new file mode 100644 index 000000000..6f5b66a23 --- /dev/null +++ b/src/lib/insights/__tests__/illness-cycle-briefing.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const buildIllnessSnapshotBlock = vi.fn(); +const buildCycleSnapshotBlock = vi.fn(); +const isCycleAvailableForUser = vi.fn(); + +vi.mock("@/lib/ai/coach/illness-snapshot", () => ({ + buildIllnessSnapshotBlock: (...a: unknown[]) => + buildIllnessSnapshotBlock(...a), +})); +vi.mock("@/lib/ai/coach/cycle-snapshot", () => ({ + buildCycleSnapshotBlock: (...a: unknown[]) => buildCycleSnapshotBlock(...a), +})); +vi.mock("@/lib/cycle/gate", () => ({ + isCycleAvailableForUser: (...a: unknown[]) => isCycleAvailableForUser(...a), +})); + +import { + buildBriefingIllnessCycleContext, + buildBriefingIllnessCyclePrompt, +} from "@/lib/insights/illness-cycle-briefing"; + +beforeEach(() => { + vi.clearAllMocks(); + isCycleAvailableForUser.mockResolvedValue(false); + buildIllnessSnapshotBlock.mockResolvedValue(null); + buildCycleSnapshotBlock.mockResolvedValue(null); +}); + +describe("buildBriefingIllnessCycleContext", () => { + it("returns null when neither module surfaces anything", async () => { + const ctx = await buildBriefingIllnessCycleContext("u1", null, "UTC"); + expect(ctx).toBeNull(); + // Cycle disabled → cycle builder never queried. + expect(buildCycleSnapshotBlock).not.toHaveBeenCalled(); + }); + + it("skips the cycle builder when the module is off but keeps illness", async () => { + buildIllnessSnapshotBlock.mockResolvedValue({ + restMode: true, + active: [ + { label: "Flu", type: "ACUTE", lifecycle: "ACTIVE", onsetAt: "x" }, + ], + recentResolved: [], + }); + const ctx = await buildBriefingIllnessCycleContext("u1", null, "UTC"); + expect(ctx).not.toBeNull(); + expect(ctx?.illness?.restMode).toBe(true); + expect(ctx?.cycle).toBeNull(); + expect(buildCycleSnapshotBlock).not.toHaveBeenCalled(); + }); + + it("queries the cycle builder when the module is on", async () => { + isCycleAvailableForUser.mockResolvedValue(true); + buildCycleSnapshotBlock.mockResolvedValue({ + phase: "LUTEAL", + dayOfCycle: 22, + goal: "GENERAL", + cyclesObserved: 3, + nextEvent: null, + phaseInsight: null, + }); + const ctx = await buildBriefingIllnessCycleContext("u1", "FEMALE", "UTC"); + expect(ctx?.cycle?.phase).toBe("LUTEAL"); + }); +}); + +describe("buildBriefingIllnessCyclePrompt", () => { + it("emits a Rest Mode line for an active illness", () => { + const out = buildBriefingIllnessCyclePrompt( + { + illness: { + restMode: true, + active: [ + { label: "Flu", type: "ACUTE", lifecycle: "ACTIVE", onsetAt: "x" }, + ], + recentResolved: [], + }, + cycle: null, + }, + "en", + ); + expect(out).toContain("Rest Mode"); + expect(out).toContain("Flu"); + expect(out).toContain("SYSTEM CONTEXT"); + }); + + it("emits a cycle day/phase line", () => { + const out = buildBriefingIllnessCyclePrompt( + { + illness: null, + cycle: { + phase: "FOLLICULAR", + dayOfCycle: 5, + goal: "GENERAL", + cyclesObserved: 2, + nextEvent: null, + phaseInsight: null, + }, + }, + "en", + ); + expect(out).toContain("day 5"); + expect(out).toContain("FOLLICULAR"); + expect(out).toContain("never causal"); + }); + + it("returns the empty string when there is nothing to say", () => { + const out = buildBriefingIllnessCyclePrompt( + { + illness: { restMode: false, active: [], recentResolved: [] }, + cycle: null, + }, + "en", + ); + expect(out).toBe(""); + }); + + it("localises to German", () => { + const out = buildBriefingIllnessCyclePrompt( + { + illness: null, + cycle: { + phase: "LUTEAL", + dayOfCycle: 20, + goal: "GENERAL", + cyclesObserved: 4, + nextEvent: null, + phaseInsight: null, + }, + }, + "de", + ); + expect(out).toContain("Zyklus"); + expect(out).toContain("Tag 20"); + }); +}); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 278c45e7e..a83582f25 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -22,6 +22,7 @@ import { prisma } from "@/lib/db"; import { extractFeatures, FeaturesPayloadTooLargeError, + BRIEFING_FEATURE_WINDOW_DAYS, type SignalOfDay, } from "@/lib/insights/features"; import { @@ -30,6 +31,10 @@ import { buildBriefingGroundingCorrection, } from "@/lib/ai/briefing-grounding"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; +import { + buildBriefingIllnessCycleContext, + buildBriefingIllnessCyclePrompt, +} from "@/lib/insights/illness-cycle-briefing"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { detectGlp1Plateau, @@ -687,6 +692,8 @@ export async function generateComprehensiveInsight( // (see the hash construction below), so it must be readable BEFORE // the gate — the comparison snapshot itself is only built after. dashboardWidgetsJson: true, + // v1.18.11 P5 — gender feeds the cycle context block (phase contrast). + gender: true, }, }); @@ -721,17 +728,25 @@ export async function generateComprehensiveInsight( } const includeRaw = dbUser?.insightsPrivacyMode === "raw"; + // v1.18.11 P1 — bound the briefing's bulk feature read to a recent window + // (all-time extremes are sourced separately, see features.ts). The downgrade + // ladder below stays as the safety net for the rare oversize payload. + const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS }; let features: Awaited>; try { - features = await extractFeatures(userId, includeRaw); + features = await extractFeatures(userId, includeRaw, featureWindow); } catch (err) { if (err instanceof FeaturesPayloadTooLargeError) { try { - features = await extractFeatures(userId, false); + features = await extractFeatures(userId, false, featureWindow); } catch (retryErr) { if (retryErr instanceof FeaturesPayloadTooLargeError) { try { - const aggregated = await extractFeatures(userId, false); + const aggregated = await extractFeatures( + userId, + false, + featureWindow, + ); features = applyInsightsExcludeFilter( aggregated, MAX_DOWNGRADE_TOKENS, @@ -818,6 +833,16 @@ export async function generateComprehensiveInsight( if (aboutMe) { userPrompt += buildAboutMeInsightBlock(aboutMe, locale); } + // v1.18.11 P5 — illness + cycle context (same builders as the on-demand + // route + the Coach). Module-gated; null for users without either module. + const illnessCycleCtx = await buildBriefingIllnessCycleContext( + userId, + dbUser?.gender ?? null, + await resolveUserTimezone(userId), + ); + if (illnessCycleCtx) { + userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale); + } const systemPrompt = buildSystemPromptWithReferences( locale, referenceMetrics, diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 590844869..6b60d622e 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -283,6 +283,69 @@ export interface RawFeatures extends AggregatedFeatures { */ export const FEATURES_MAX_BYTES = 5 * 1024 * 1024; +/** + * v1.18.11 P1 — default bounded read window for the comprehensive briefing. + * + * The briefing's prose only ever cites 7 / 30 / 90-day windows plus all-time + * extremes; the unbounded `measurement.findMany` it used to run materialised + * the entire history (tens to hundreds of thousands of rows for multi-year + * Withings / Apple imports) into JS on every generation, only to trim the + * payload after the fact through the 5 MB downgrade ladder. 400 days captures + * full history for the overwhelming majority of accounts while keeping the + * seasonal-BP contrast (winter vs summer, needs >180 d) intact; the genuinely + * long-horizon all-time min/max/avg figures are sourced separately from a + * single grouped aggregation over the whole history (see + * `readAllTimeExtremes`), so bounding the bulk read does not silently relabel a + * 400-day extreme as "all-time". Mirrors the Coach's 90-day bound + * (`coach/snapshot.ts`), just wider because the briefing narrates longer trends. + */ +export const BRIEFING_FEATURE_WINDOW_DAYS = 400; + +/** All-time aggregate (full history) for one measurement type. */ +interface AllTimeExtremes { + mean: number | null; + min: number | null; + max: number | null; +} + +/** + * v1.18.11 P1 — full-history min / max / mean per measurement type via ONE + * grouped SQL aggregation, with NO row materialisation in JS. Used to fill the + * `allTime*` feature fields honestly when the bulk feature read is bounded to a + * recent window: the windowed `summarize()` covers trends + recent windows, and + * this covers the long-horizon extremes the prompt labels "allTime". + * + * Only the four types that expose `allTime*` fields are aggregated (weight, + * systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type + * with no rows is simply absent. + */ +async function readAllTimeExtremes( + userId: string, + types: readonly MeasurementType[], +): Promise> { + const rows = await prisma.measurement.groupBy({ + by: ["type"], + where: { userId, deletedAt: null, type: { in: [...types] } }, + _avg: { value: true }, + _min: { value: true }, + _max: { value: true }, + }); + const out = new Map(); + for (const r of rows) { + out.set(r.type, { + mean: r._avg.value ?? null, + min: r._min.value ?? null, + max: r._max.value ?? null, + }); + } + return out; +} + +/** Round an all-time mean to 1 decimal, matching the windowed `summarize` mean. */ +function roundMean(v: number | null): number | null { + return v === null ? null : Math.round(v * 10) / 10; +} + export class FeaturesPayloadTooLargeError extends Error { readonly code = "FEATURES_PAYLOAD_TOO_LARGE"; readonly sizeBytes: number; @@ -620,6 +683,21 @@ export async function extractFeatures( orderBy: { measuredAt: "asc" }, }); + // v1.18.11 P1 — when the bulk read is bounded to a recent window, the + // windowed `summarize()` no longer covers the full history, so the `allTime*` + // fields would silently become "last `sinceDays` days" extremes. Source the + // genuine full-history min / max / mean from one grouped aggregation (no row + // materialisation) so the labels stay honest. Unbounded reads (sinceCutoff + // null) already see the whole history and skip the extra query. + const allTimeExtremes = sinceCutoff + ? await readAllTimeExtremes(userId, [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + ]) + : null; + const byType = (type: string) => measurements.filter((m) => m.type === type); const bpTargets = getBpTargets(user?.dateOfBirth ?? null); @@ -686,9 +764,23 @@ export async function extractFeatures( avg7: summary.avg7, avg30: summary.avg30, avg90: avgInWindow(weightData, now, 90), - allTimeAvg: summary.count > 0 ? summary.mean : null, - allTimeMin: summary.count > 0 ? summary.min : null, - allTimeMax: summary.count > 0 ? summary.max : null, + // v1.18.11 P1 — full-history extremes from the grouped aggregation when + // the bulk read is windowed; the in-window summary otherwise. + allTimeAvg: allTimeExtremes + ? roundMean(allTimeExtremes.get("WEIGHT")?.mean ?? null) + : summary.count > 0 + ? summary.mean + : null, + allTimeMin: allTimeExtremes + ? (allTimeExtremes.get("WEIGHT")?.min ?? null) + : summary.count > 0 + ? summary.min + : null, + allTimeMax: allTimeExtremes + ? (allTimeExtremes.get("WEIGHT")?.max ?? null) + : summary.count > 0 + ? summary.max + : null, slope30: summary.slope30?.slope ?? null, outlierCount: summary.anomalyCount, bmi, @@ -733,12 +825,37 @@ export async function extractFeatures( avgDia30: diaSummary?.avg30 ?? null, avgSys90: sysData.length > 0 ? avgInWindow(sysData, now, 90) : null, avgDia90: diaData.length > 0 ? avgInWindow(diaData, now, 90) : null, - allTimeAvgSys: sysSummary?.count ? sysSummary.mean : null, - allTimeAvgDia: diaSummary?.count ? diaSummary.mean : null, - allTimeMinSys: sysSummary?.count ? sysSummary.min : null, - allTimeMaxSys: sysSummary?.count ? sysSummary.max : null, - allTimeMinDia: diaSummary?.count ? diaSummary.min : null, - allTimeMaxDia: diaSummary?.count ? diaSummary.max : null, + // v1.18.11 P1 — full-history extremes when the bulk read is windowed. + allTimeAvgSys: allTimeExtremes + ? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.mean ?? null) + : sysSummary?.count + ? sysSummary.mean + : null, + allTimeAvgDia: allTimeExtremes + ? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.mean ?? null) + : diaSummary?.count + ? diaSummary.mean + : null, + allTimeMinSys: allTimeExtremes + ? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.min ?? null) + : sysSummary?.count + ? sysSummary.min + : null, + allTimeMaxSys: allTimeExtremes + ? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.max ?? null) + : sysSummary?.count + ? sysSummary.max + : null, + allTimeMinDia: allTimeExtremes + ? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.min ?? null) + : diaSummary?.count + ? diaSummary.min + : null, + allTimeMaxDia: allTimeExtremes + ? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.max ?? null) + : diaSummary?.count + ? diaSummary.max + : null, slopeSys30: sysSummary?.slope30?.slope ?? null, slopeDia30: diaSummary?.slope30?.slope ?? null, sdSys30: (() => { @@ -779,9 +896,22 @@ export async function extractFeatures( avg7: summary.avg7, avg30: summary.avg30, avg90: avgInWindow(pulseData, now, 90), - allTimeAvg: summary.count > 0 ? summary.mean : null, - allTimeMin: summary.count > 0 ? summary.min : null, - allTimeMax: summary.count > 0 ? summary.max : null, + // v1.18.11 P1 — full-history extremes when the bulk read is windowed. + allTimeAvg: allTimeExtremes + ? roundMean(allTimeExtremes.get("PULSE")?.mean ?? null) + : summary.count > 0 + ? summary.mean + : null, + allTimeMin: allTimeExtremes + ? (allTimeExtremes.get("PULSE")?.min ?? null) + : summary.count > 0 + ? summary.min + : null, + allTimeMax: allTimeExtremes + ? (allTimeExtremes.get("PULSE")?.max ?? null) + : summary.count > 0 + ? summary.max + : null, slope30: summary.slope30?.slope ?? null, anomalyCount: summary.anomalyCount, coverage: computeCoverage(pulseData, now), diff --git a/src/lib/insights/illness-cycle-briefing.ts b/src/lib/insights/illness-cycle-briefing.ts new file mode 100644 index 000000000..f7fd7974d --- /dev/null +++ b/src/lib/insights/illness-cycle-briefing.ts @@ -0,0 +1,132 @@ +/** + * v1.18.11 P5 — illness + cycle context for the daily briefing. + * + * The briefing used to narrate as if nothing were going on even when the user + * is mid-illness-episode or mid-cycle, because — unlike the Coach — it never + * pulled those states into its prompt. This module reuses the SAME server- + * authoritative builders the Coach snapshot assembles (`buildIllnessSnapshotBlock`, + * `buildCycleSnapshotBlock`) and folds the result into the insight-generator + * prompt as a SYSTEM CONTEXT block — the same append mechanism the GLP-1 plateau + * and derived-signal detectors use. + * + * Both blocks are module-gated: the illness builder short-circuits to null for a + * non-illness / opted-out account, and the cycle block is additionally gated on + * the fully-resolved cycle module. A user with neither pays zero token cost and + * the briefing prompt is byte-for-byte unchanged. CONTEXT only — labels + + * lifecycle + dates + phase; no decrypted notes, no fabricated numbers. + * + * Server-only. + */ +import { + buildIllnessSnapshotBlock, + type CoachIllnessBlock, +} from "@/lib/ai/coach/illness-snapshot"; +import { + buildCycleSnapshotBlock, + type CycleSnapshotBlock, +} from "@/lib/ai/coach/cycle-snapshot"; +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; +import type { Locale } from "@/lib/i18n/config"; + +export interface BriefingIllnessCycleContext { + illness: CoachIllnessBlock | null; + cycle: CycleSnapshotBlock | null; +} + +/** + * Assemble the illness + cycle context for the briefing, or `null` when neither + * module surfaces anything. Reuses the Coach builders verbatim so the briefing + * never disagrees with the Coach about the user's current state. + */ +export async function buildBriefingIllnessCycleContext( + userId: string, + gender: string | null | undefined, + timezone: string | null | undefined, + now: Date = new Date(), +): Promise { + const cycleEnabled = await isCycleAvailableForUser(userId); + const [illness, cycle] = await Promise.all([ + // Module-gated internally — short-circuits to null for a non-illness account. + buildIllnessSnapshotBlock(userId, now), + cycleEnabled + ? buildCycleSnapshotBlock(userId, gender, now, timezone) + : Promise.resolve(null), + ]); + + if (!illness && !cycle) return null; + return { illness, cycle }; +} + +/** + * Build the SYSTEM CONTEXT block appended to the briefing user prompt. Mirrors + * the derived-briefing / GLP-1 plateau append mechanism. DE + EN bodies; other + * locales fall through to EN (the same chain the message bundles use). + * + * Returns the empty string when there is nothing to say, so the caller can + * append unconditionally. + */ +export function buildBriefingIllnessCyclePrompt( + ctx: BriefingIllnessCycleContext, + locale: Locale, +): string { + const de = locale === "de"; + const lines: string[] = []; + + if (ctx.illness) { + if (ctx.illness.restMode && ctx.illness.active.length > 0) { + const labels = ctx.illness.active + .map((e) => `${e.label} (${e.type}, ${e.lifecycle})`) + .join("; "); + lines.push( + de + ? `- Aktive Erkrankung(en) gerade: ${labels}. Der Nutzer ist im Rest Mode — lies abweichende Vitalwerte oder eine niedrige Erholung als krankheitsbedingt, nicht alarmierend, und dränge NICHT auf häufigeres Messen.` + : `- Active illness episode(s) right now: ${labels}. The user is in Rest Mode — read off-band vitals or low recovery as illness-explained rather than alarming, and do NOT push a "measure more often" cadence.`, + ); + } else if (ctx.illness.recentResolved.length > 0) { + lines.push( + de + ? `- Keine aktive Erkrankung; zuletzt aufgelöst: ${ctx.illness.recentResolved[0].label}.` + : `- No active illness; most recently resolved: ${ctx.illness.recentResolved[0].label}.`, + ); + } + } + + if (ctx.cycle) { + const c = ctx.cycle; + if (c.phase && c.dayOfCycle !== null) { + lines.push( + de + ? `- Zyklus: Tag ${c.dayOfCycle}, ${c.phase}-Phase. Beschreibend, nie als Ursache; keine Verhütungs- oder "sichere Tage"-Aussagen.` + : `- Cycle: day ${c.dayOfCycle}, ${c.phase} phase. Descriptive only, never causal; no contraception / "safe day" claims.`, + ); + } + if (c.phaseInsight) { + lines.push( + de + ? `- ${c.phaseInsight.interpretation}` + : `- ${c.phaseInsight.interpretation}`, + ); + } + } + + if (lines.length === 0) return ""; + + const body = lines.join("\n"); + return de + ? ` + +SYSTEM CONTEXT — GESUNDHEITSZUSTAND (KRANKHEIT / ZYKLUS) + +Die App hat folgenden aktuellen Kontext aus den eigenen Daten des Nutzers ermittelt (beschreibend, keine Diagnose): +${body} + +Berücksichtige diesen Kontext im dailyBriefing-Ton; erfinde KEINE Zahlen und stelle KEINE medizinische Ursache her.` + : ` + +SYSTEM CONTEXT — HEALTH STATE (ILLNESS / CYCLE) + +The app derived this current context from the user's own data (descriptive, not a diagnosis): +${body} + +Factor this context into the dailyBriefing tone; do NOT invent numbers and do NOT attribute a medical cause.`; +}