mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
461 lines
13 KiB
TypeScript
461 lines
13 KiB
TypeScript
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);
|
||
}
|