mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Opening an Insights category page froze the whole UI: the per-metric status card fired a cold, uncapped LLM fetch on mount with no client abort, so the request hung up to the server's 60s ceiling and the pending/resolve re-renders thrashed the tab strip and ate touch events. Timeouts were never negatively cached, so every visit re-blocked. Fix the class holistically: - Bound every status GET on the client. The shared use-insight-status hook and the inline medication-compliance query now wrap fetch in an 8s AbortController and stop retrying inline; navigation can no longer await an uncapped round-trip. - Make the status routes read-only. On a cache miss the generator enqueues an out-of-band pg-boss job (insight-status-generate) and returns a preparing envelope; the client polls a bounded interval until the worker warms the cache. Models the dashboard snapshot's ready/preparing pattern. The queue is registered in the worker allQueues loop + a boss.work handler, with a guard test. - Fix the nightly warm-pass regression: warm every supported locale (the client reads against the active UI locale, not the persisted User.locale) and warm on a cached comprehensive outcome too (its write still evicts the per-status caches). Add a short-TTL negative cache on the timeout path so a transient provider stall no longer makes every subsequent visit re-block or re-enqueue. - Add loading.tsx for the insights segment + the root so any future RSC await streams a skeleton instead of blocking the transition. - Stabilise the advisor regenerate callback so a status-query flip can't re-render the memoised tab strip mid-gesture. New i18n key insights.assessmentPreparing across all six locales.
450 lines
12 KiB
TypeScript
450 lines
12 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 {
|
||
buildGradedSeriesFromPoints,
|
||
degradeStatusSnapshotToBudget,
|
||
} from "@/lib/insights/graded-series";
|
||
import {
|
||
type SupportedLocale,
|
||
normalizeLocale,
|
||
normalizeSummaryText,
|
||
parseSummaryFromContent,
|
||
persistStatusInsight,
|
||
round,
|
||
summarizeSeries,
|
||
} from "@/lib/insights/status-shared";
|
||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||
import {
|
||
readFreshStatusText,
|
||
resolveReadOnlyStatusMiss,
|
||
} from "@/lib/insights/status-cache";
|
||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||
import { annotate } from "@/lib/logging/context";
|
||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||
|
||
/**
|
||
* 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;
|
||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||
readOnly?: boolean;
|
||
},
|
||
): Promise<{
|
||
hasProvider: boolean;
|
||
text: string | null;
|
||
cached: boolean;
|
||
updatedAt: string | null;
|
||
preparing?: boolean;
|
||
}> {
|
||
const locale = normalizeLocale(options?.locale);
|
||
const force = options?.force === true;
|
||
const readOnly = options?.readOnly === 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,
|
||
};
|
||
}
|
||
|
||
if (readOnly) {
|
||
const outcome = await resolveReadOnlyStatusMiss({
|
||
userId,
|
||
metric: "mood",
|
||
locale,
|
||
});
|
||
if (outcome === "no-provider") {
|
||
return {
|
||
hasProvider: false,
|
||
text: getNoKeyMoodStatusText(locale),
|
||
cached: true,
|
||
updatedAt: null,
|
||
};
|
||
}
|
||
return {
|
||
hasProvider: true,
|
||
text: null,
|
||
cached: false,
|
||
updatedAt: null,
|
||
preparing: true,
|
||
};
|
||
}
|
||
|
||
// 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 moodPoints = entries.map((entry) => ({
|
||
measuredAt: entry.moodLoggedAt,
|
||
value: entry.score,
|
||
}));
|
||
// `applyPayloadBudget` daily buckets drive the in-target %, latest,
|
||
// and correlations; the compact graded series reaches the prompt.
|
||
const moodSeries = applyPayloadBudget(moodPoints, { now });
|
||
const moodGraded = buildGradedSeriesFromPoints(moodPoints, 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: moodGraded,
|
||
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 shed = degradeStatusSnapshotToBudget(
|
||
snapshot as unknown as Record<string, unknown>,
|
||
);
|
||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||
|
||
annotate({
|
||
action: { name: cacheAction },
|
||
meta: {
|
||
payload_size_bytes: snapshotJson.length,
|
||
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
|
||
},
|
||
});
|
||
|
||
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,
|
||
userId,
|
||
todayKey,
|
||
stubText: getNoKeyMoodStatusText(locale),
|
||
});
|
||
}
|
||
|
||
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||
if (!summary) {
|
||
throw new Error("Mood-status summary was empty after normalization");
|
||
}
|
||
|
||
const updatedAt = await persistStatusInsight({
|
||
userId,
|
||
cacheAction,
|
||
todayKey,
|
||
locale,
|
||
text: summary,
|
||
providerType: outcome.providerType,
|
||
model: outcome.model,
|
||
tokensUsed: outcome.tokensUsed,
|
||
});
|
||
|
||
return {
|
||
hasProvider: true,
|
||
text: summary,
|
||
cached: false,
|
||
updatedAt,
|
||
};
|
||
}
|
||
|
||
export function resolveMoodStatusLocale(
|
||
locale: string | null | undefined,
|
||
): SupportedLocale {
|
||
return normalizeLocale(locale);
|
||
}
|