perf(insights): bound briefing feature window and add illness/cycle context

Bound the comprehensive briefing's feature read to a 400-day window instead of
walking the full measurement history on every generation. All-time min/max/avg
are sourced from one grouped aggregation over the whole history, so the
allTime* fields stay honest while the unbounded row materialisation is gone;
the oversize downgrade ladder remains as the safety net.

Fold illness-episode and cycle state into the briefing prompt, reusing the
server-authoritative builders the Coach already assembles. Both are
module-gated and cost nothing for users without them, so a mid-illness or
mid-cycle user finally gets context-aware prose.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 19:04:08 +02:00
1 parent 4aebaaf399
commit 8479ea001b
8 files changed
+483 -18

No files matched your search

+29 -3
View File
@@ -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
@@ -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),
@@ -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.
@@ -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),
@@ -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");
});
});
+28 -3
View File
@@ -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<ReturnType<typeof extractFeatures>>;
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,
+142 -12
View File
@@ -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<Map<MeasurementType, AllTimeExtremes>> {
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<MeasurementType, AllTimeExtremes>();
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),
+132
View File
@@ -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<BriefingIllnessCycleContext | null> {
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.`;
}