Files
HealthLog/src/lib/insights/mood-status.ts
T
Marc-André Bombeck 5432063f5c fix(insights): stop caching the timeout fallback and align the status timeout with the coach
The per-card status path capped provider generation at 20 s and, on
timeout, persisted the generic no-key fallback as a day-keyed cache row.
The cache-read only checked the day key and text, so that stub looked
like a real assessment and stuck until midnight — hiding the real,
data-driven text for the rest of the day on any account that hit a
single transient stall.

Align the budget with the provider clients' 60 s ceiling, route the
status path through resolveProviderChain + runRawCompletionWithFallback
so a degraded provider cascades like the coach, and stop persisting the
fallback: a timeout or error is a transient miss that serves the
fallback for the current render and re-attempts on the next mount. A
shared cache-read rejects any lingering timeout-stub rows by their
markers, and withTimeout now annotates real errors instead of folding
them silently into the timeout path.
2026-05-31 21:51:18 +02:00

461 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { prisma } from "@/lib/db";
import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood";
import { getNoKeyMoodStatusText } from "@/lib/insights/no-key-fallbacks";
import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
pearsonCorrelation,
type PairedPoint,
} from "@/lib/analytics/correlations";
import {
applyPayloadBudget,
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
/**
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
* field is anchored at the UTC midnight of the Berlin calendar day —
* `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries
* don't slip the day-key by one. Each pair also carries `dayKey`
* directly so callers can label points without re-formatting.
*/
function pairDailyBuckets(
seriesA: DailyBucket[],
seriesB: DailyBucket[],
now: Date,
): Array<PairedPoint & { dayKey: string }> {
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
return seriesA
.map((entry) => {
const b = mapB.get(entry.dayOffset);
if (b == null) return null;
const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset);
const [y, m, d] = dayKey.split("-").map(Number);
return {
a: entry.value,
b,
date: new Date(Date.UTC(y, m - 1, d)),
dayKey,
};
})
.filter(
(entry): entry is PairedPoint & { dayKey: string } => entry !== null,
);
}
export async function generateMoodStatusForUser(
userId: string,
options?: {
locale?: string | null;
force?: boolean;
},
): Promise<{
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const cacheAction = `insights.mood-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
// v1.4.28 FB-D2 — cap at 90 entries (~3 months) for the mood
// surface. The downstream `applyPayloadBudget` slims further.
// Order desc + reverse so the per-row consumers downstream (which
// expect oldest-first) see the same shape.
const entries = await prisma.moodEntry
.findMany({
where: {
userId,
},
orderBy: { date: "desc" },
take: 90,
select: {
date: true,
score: true,
tags: true,
moodLoggedAt: true,
},
})
.then((rows) => rows.reverse());
const now = new Date();
const moodSeries = applyPayloadBudget(
entries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
})),
{ now },
);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
const greenMin = 3.5;
const greenMax = 5;
const orangeMin = 2;
const orangeMax = 3.5;
// "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29).
const recentMoodDaily = moodSeries.daily.filter(
(bucket) => bucket.dayOffset < 30,
);
const inTargetPctLast30DailyPoints =
recentMoodDaily.length === 0
? null
: round(
(recentMoodDaily.filter(
(entry) => entry.value >= greenMin && entry.value <= greenMax,
).length /
recentMoodDaily.length) *
100,
1,
);
// daily[0] = newest bucket (lowest dayOffset).
const latestMood = moodSeries.daily[0] ?? null;
const previousMood = moodSeries.daily[1] ?? null;
const oldestEntry = entries.length > 0 ? entries[0].moodLoggedAt : null;
const newestEntry =
entries.length > 0 ? entries[entries.length - 1].moodLoggedAt : null;
const totalSpanDays =
oldestEntry && newestEntry
? Math.round(
(newestEntry.getTime() - oldestEntry.getTime()) /
(24 * 60 * 60 * 1000),
)
: 0;
const newestEntryDaysAgo = newestEntry
? Math.round((Date.now() - newestEntry.getTime()) / (24 * 60 * 60 * 1000))
: null;
// Fetch cross-metric context for enrichment. v1.4.28 FB-D2 — cap at
// 1095 = 365 d × 3 channels so power users do not pull unbounded
// rows. Order desc + reverse so the downstream filters see oldest-
// first as before.
const measurements = await prisma.measurement
.findMany({
where: {
userId,
type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] },
},
orderBy: { measuredAt: "desc" },
take: 1095,
select: { type: true, value: true, measuredAt: true },
})
.then((rows) => rows.reverse());
const weightSeries = applyPayloadBudget(
measurements
.filter((m) => m.type === "WEIGHT")
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
{ now },
);
const sysSeries = applyPayloadBudget(
measurements
.filter((m) => m.type === "BLOOD_PRESSURE_SYS")
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
{ now },
);
const pulseSeries = applyPayloadBudget(
measurements
.filter((m) => m.type === "PULSE")
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
{ now },
);
// Correlations between mood and other metrics — pair on dayOffset so
// the bucketed daily window aligns across metrics.
const moodVsWeightPairs = pairDailyBuckets(
moodSeries.daily,
weightSeries.daily,
now,
);
const moodVsWeightCorrelation = pearsonCorrelation(moodVsWeightPairs);
const moodVsSysPairs = pairDailyBuckets(
moodSeries.daily,
sysSeries.daily,
now,
);
const moodVsSysCorrelation = pearsonCorrelation(moodVsSysPairs);
const moodVsPulsePairs = pairDailyBuckets(
moodSeries.daily,
pulseSeries.daily,
now,
);
const moodVsPulseCorrelation = pearsonCorrelation(moodVsPulsePairs);
// Extract tag frequencies from recent entries — keep the v1.4.5
// ~90-day window so the model still gets a recency-weighted view of
// tag patterns. The mood DB query already pulls 3 years (no where
// filter); we slice by date here, not by record count.
const tagWindowCutoff = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
const recentEntries = entries.filter(
(entry) => entry.moodLoggedAt.getTime() >= tagWindowCutoff.getTime(),
);
const tagCounts = new Map<string, { count: number; scoreSum: number }>();
for (const entry of recentEntries) {
if (entry.tags && Array.isArray(entry.tags)) {
for (const tag of entry.tags as string[]) {
const current = tagCounts.get(tag) ?? { count: 0, scoreSum: 0 };
current.count += 1;
current.scoreSum += entry.score;
tagCounts.set(tag, current);
}
}
}
const tagSummary = Array.from(tagCounts.entries())
.filter(([, stats]) => stats.count >= 2)
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 10)
.map(([tag, stats]) => ({
tag,
count: stats.count,
avgScore: round(stats.scoreSum / stats.count, 2),
}));
const snapshot = {
locale,
generatedForDay: todayKey,
focus: "mood",
dataCoverage: {
totalEntries: entries.length,
totalSpanDays,
newestEntryDaysAgo,
},
mood: {
summary: moodSummary,
series: moodSeries,
latestDayFocus: latestMood
? {
dayOffset: latestMood.dayOffset,
value: latestMood.value,
deltaToPreviousDailyPoint:
previousMood == null
? null
: round(latestMood.value - previousMood.value, 2),
}
: null,
target: {
greenMin,
greenMax,
orangeMin,
orangeMax,
inTargetPctLast30DailyPoints,
},
tags: tagSummary.length > 0 ? tagSummary : null,
},
crossMetricContext:
weightSeries.daily.length >= 3 ||
sysSeries.daily.length >= 3 ||
pulseSeries.daily.length >= 3
? {
weight:
weightSeries.daily.length >= 3
? {
summary: summarizeSeries(
weightSeries.daily.map((bucket) => ({
value: bucket.value,
})),
),
correlation: moodVsWeightCorrelation,
}
: null,
bloodPressureSystolic:
sysSeries.daily.length >= 3
? {
summary: summarizeSeries(
sysSeries.daily.map((bucket) => ({
value: bucket.value,
})),
),
correlation: moodVsSysCorrelation,
}
: null,
pulse:
pulseSeries.daily.length >= 3
? {
summary: summarizeSeries(
pulseSeries.daily.map((bucket) => ({
value: bucket.value,
})),
),
correlation: moodVsPulseCorrelation,
}
: null,
}
: null,
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
});
const previousContext = await getPreviousInsightContext(
userId,
"mood-status",
locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
locale,
);
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyMoodStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyMoodStatusText(locale),
});
}
let summary = "";
try {
const parsed = JSON.parse(outcome.content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = outcome.content;
}
} catch {
summary = outcome.content;
}
summary = normalizeSummaryText(summary);
if (!summary) {
throw new Error("Mood-status summary was empty after normalization");
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
};
}
export function resolveMoodStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
}