mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
4aebaaf399
commit
8479ea001b
8 files changed
+483
-18
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
Reference in new issue
Block a user