mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Per-status texts check AI consent before the unchanged-data refresh can re-stamp them, the comprehensive fingerprint folds in the comparison baseline and generation language so toggling either regenerates once, and profile, threshold and source-priority writes evict the analytics, targets and derived caches they feed. CSV export neutralises spreadsheet formula prefixes on free-text cells while numeric columns stay typed, the derived batch validates its type token against the measurement enum and carries a per-user rate limit, and a dose synced from the phone app counts as the interactive write it is. Background cache refresh failures log a structured event instead of vanishing, the warm queue runs visit-triggered jobs alongside the nightly walk, the formatter memo evicts one entry instead of flushing wholesale with its option objects frozen, the targets cold build bounds its fan-out, and time in bed unions every writer's envelope while stages stay with the richest recorder.
797 lines
31 KiB
TypeScript
797 lines
31 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import {
|
|
apiSuccess,
|
|
apiError,
|
|
getClientIp,
|
|
safeJson,
|
|
} from "@/lib/api-response";
|
|
import {
|
|
extractFeatures,
|
|
FeaturesPayloadTooLargeError,
|
|
} from "@/lib/insights/features";
|
|
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
|
|
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
|
import {
|
|
detectGlp1Plateau,
|
|
buildGlp1PlateauPrompt,
|
|
} from "@/lib/insights/glp1-plateau";
|
|
import {
|
|
detectDerivedBriefingSignals,
|
|
buildDerivedBriefingPrompt,
|
|
} from "@/lib/insights/derived-briefing";
|
|
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
|
import {
|
|
buildUserPrompt,
|
|
type ComparisonSnapshot,
|
|
} from "@/lib/ai/prompts/insight-system-prompt";
|
|
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
|
|
import {
|
|
buildAboutMeInsightBlock,
|
|
getSelfContextTextForUser,
|
|
} from "@/lib/ai/coach/about-me";
|
|
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
|
|
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
|
import {
|
|
reconstructSleepNights,
|
|
type SleepStageRow,
|
|
} from "@/lib/analytics/sleep-night";
|
|
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
|
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
|
import {
|
|
resolveDashboardLayout,
|
|
type ComparisonBaseline,
|
|
} from "@/lib/dashboard-layout";
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
|
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
|
import {
|
|
AllProvidersFailedError,
|
|
runRawCompletionWithFallback,
|
|
} from "@/lib/ai/provider-runner";
|
|
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
|
import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { NextRequest } from "next/server";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { requireAssistantSurface } from "@/lib/feature-flags";
|
|
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
|
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
|
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
|
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
|
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/**
|
|
* Briefing freshness window for the read-only GET. A cached briefing read
|
|
* this recently is fresh and triggers no warm; older / missing enqueues an
|
|
* out-of-band regeneration. Mirrors the 24 h cache-hit window the POST path
|
|
* honours so the two stay consistent.
|
|
*/
|
|
const BRIEFING_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR = 10;
|
|
|
|
/**
|
|
* Reads `INSIGHTS_RATE_LIMIT_PER_HOUR` from the environment with a
|
|
* sensible 10/hour default. The previous hard-coded 2/hour limit (from
|
|
* v1.4 P13) was too aggressive for users iterating on settings or
|
|
* regenerating after adding measurements. Operators who run on a
|
|
* stricter LLM budget can dial it down via env without redeploying
|
|
* code; values <1 fall back to the default.
|
|
*/
|
|
export function resolveInsightsRateLimit(): number {
|
|
const raw = process.env.INSIGHTS_RATE_LIMIT_PER_HOUR;
|
|
if (!raw) return DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR;
|
|
const parsed = parseInt(raw, 10);
|
|
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
return DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/**
|
|
* v1.4.16 phase B8 — fetch the user's persisted comparison toggle and
|
|
* build a `ComparisonSnapshot` for the prompt builder. Returns null
|
|
* when comparison is off (most users), so the prompt builder skips
|
|
* the context block entirely.
|
|
*
|
|
* Snapshot construction reuses the analytics `summarize()` helper
|
|
* (which now emits `avg30LastMonth` + `avg30LastYear`) so the numbers
|
|
* the LLM sees match the numbers the dashboard tile renders — no
|
|
* second source of truth for the comparison delta.
|
|
*/
|
|
async function buildComparisonSnapshotForUser(
|
|
userId: string,
|
|
): Promise<ComparisonSnapshot | null> {
|
|
// The features payload doesn't carry prior-period values — pulling
|
|
// a fresh `summarize()` per metric below keeps the snapshot
|
|
// self-contained without retrofitting features.ts.
|
|
const row = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { dashboardWidgetsJson: true },
|
|
});
|
|
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
|
const baseline: ComparisonBaseline = layout.comparisonBaseline ?? "none";
|
|
if (baseline === "none") return null;
|
|
|
|
// Pull every measurement type once and run summarize() so the
|
|
// prior-period averages line up with what the dashboard analytics
|
|
// computes. Only include rows where the LLM can sensibly narrate
|
|
// a prior comparison (i.e. the metric exists in our snapshot type
|
|
// mapping below).
|
|
const typeToSnapshotKey: Record<string, string> = {
|
|
WEIGHT: "weight",
|
|
BLOOD_PRESSURE_SYS: "bloodPressureSys",
|
|
BLOOD_PRESSURE_DIA: "bloodPressureDia",
|
|
PULSE: "pulse",
|
|
BODY_FAT: "bodyFat",
|
|
SLEEP_DURATION: "sleep",
|
|
ACTIVITY_STEPS: "steps",
|
|
};
|
|
const typeUnits: Record<string, string> = {
|
|
WEIGHT: "kg",
|
|
BLOOD_PRESSURE_SYS: "mmHg",
|
|
BLOOD_PRESSURE_DIA: "mmHg",
|
|
PULSE: "bpm",
|
|
BODY_FAT: "%",
|
|
SLEEP_DURATION: "h",
|
|
ACTIVITY_STEPS: "",
|
|
};
|
|
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
|
|
const rows = await Promise.all(
|
|
types.map(async (type) => {
|
|
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
|
|
// raw stage rows mislabels a per-stage average as a night total (and
|
|
// double-counts a bare ASLEEP aggregate against its granular twin). Route
|
|
// the comparison through the per-night dedup reconstruction so the
|
|
// current/baseline averages are per-night TIME-ASLEEP totals in minutes.
|
|
let dataPoints: DataPoint[];
|
|
if (type === "SLEEP_DURATION") {
|
|
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
|
|
prisma.measurement.findMany({
|
|
where: { userId, type, deletedAt: null },
|
|
orderBy: { measuredAt: "asc" },
|
|
select: {
|
|
measuredAt: true,
|
|
value: true,
|
|
sleepStage: true,
|
|
source: true,
|
|
// Writer-level collapse: two HealthKit apps behind one
|
|
// source (watch stages vs phone in-bed) must not blend.
|
|
deviceType: true,
|
|
},
|
|
}),
|
|
resolveUserTimezone(userId),
|
|
loadUserSourcePriority(userId),
|
|
]);
|
|
dataPoints = reconstructSleepNights(
|
|
sleepRows as SleepStageRow[],
|
|
sleepTz,
|
|
sleepPriority,
|
|
)
|
|
.filter((n) => n.asleepMinutes > 0)
|
|
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
|
|
} else {
|
|
const measurements = await prisma.measurement.findMany({
|
|
where: { userId, type, deletedAt: null },
|
|
orderBy: { measuredAt: "asc" },
|
|
select: { measuredAt: true, value: true },
|
|
});
|
|
dataPoints = measurements.map(
|
|
(m): DataPoint => ({ date: m.measuredAt, value: m.value }),
|
|
);
|
|
}
|
|
const summary = summarize(dataPoints);
|
|
const baselineAvg =
|
|
baseline === "lastMonth"
|
|
? (summary.avg30LastMonth ?? null)
|
|
: (summary.avg30LastYear ?? null);
|
|
const currentAvg = summary.avg30 ?? null;
|
|
const delta =
|
|
currentAvg !== null && baselineAvg !== null
|
|
? Math.round((currentAvg - baselineAvg) * 100) / 100
|
|
: null;
|
|
const deltaPercent =
|
|
delta !== null && baselineAvg !== null && baselineAvg !== 0
|
|
? Math.round((delta / Math.abs(baselineAvg)) * 100 * 10) / 10
|
|
: null;
|
|
return {
|
|
type: typeToSnapshotKey[type] ?? type,
|
|
currentAvg,
|
|
baselineAvg,
|
|
delta,
|
|
deltaPercent,
|
|
unit: typeUnits[type] ?? "",
|
|
};
|
|
}),
|
|
);
|
|
|
|
// Drop fully-empty rows (current AND baseline missing) so the prompt
|
|
// doesn't pad with noise. Keep partial rows (one side null) — the
|
|
// block renderer flags them with "no prior-period data available"
|
|
// which is useful context for the model.
|
|
const metrics = rows.filter(
|
|
(row) => row.currentAvg !== null || row.baselineAvg !== null,
|
|
);
|
|
|
|
return { baseline, metrics };
|
|
}
|
|
|
|
/**
|
|
* GET /api/insights/generate — read-only advisor read.
|
|
*
|
|
* The advisor consumers (hero strip, daily-briefing card, trends row) used
|
|
* to mount against the POST handler, which generates inline on a cache miss
|
|
* and blocks the page-load path on the full provider chain (up to the
|
|
* client's 8 s abort). This read-only GET serves the cached payload from
|
|
* `User.insightsCachedText` immediately — NEVER calling the provider — and,
|
|
* when the cache is stale / missing AND a provider is configured, enqueues
|
|
* an out-of-band warm (the same `insight-pregenerate` queue the nightly
|
|
* cron and the "prepare assessments" button use). The next read reflects
|
|
* the fresh briefing. User-initiated regeneration stays on the POST path.
|
|
*
|
|
* `userId` is narrowed from the session / Bearer — never a body field.
|
|
*/
|
|
export const GET = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
// Same surface gate as the POST + the read-only status routes: a user
|
|
// with assessments enabled but Coach disabled still reads the cached
|
|
// briefing.
|
|
await requireAssistantSurface("coach");
|
|
const userId = user.id;
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
insightsCachedAt: true,
|
|
insightsCachedText: true,
|
|
locale: true,
|
|
},
|
|
});
|
|
|
|
const cachedAt = dbUser?.insightsCachedAt ?? null;
|
|
const isFresh =
|
|
cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
|
|
|
|
// Read-only: never block on the provider. Warm out of band only when the
|
|
// cached briefing is stale / missing AND a provider is configured (a
|
|
// provider-less account costs one cheap chain-resolve and shows the
|
|
// empty / connect-AI state instead of a wasted enqueue).
|
|
let revalidating = false;
|
|
if (!isFresh && (await hasUsableStatusProvider(userId))) {
|
|
// Resolve the locale the caller is actually reading (cookie /
|
|
// Accept-Language fall-back when `User.locale` is unset) and narrow
|
|
// non-German to English — the same convention the nightly's
|
|
// `normalizeLocale` and the status routes follow. The previous
|
|
// `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale`
|
|
// to German even for a client reading the app in English, so the
|
|
// visit-triggered warm filled the wrong cache family.
|
|
const resolved = await resolveServerLocale({
|
|
request,
|
|
userLocale: dbUser?.locale ?? user.locale ?? null,
|
|
});
|
|
const locale = resolved === "de" ? "de" : "en";
|
|
void enqueueForceWarm({ userId, locale });
|
|
revalidating = true;
|
|
}
|
|
|
|
if (dbUser?.insightsCachedText) {
|
|
try {
|
|
const cached = JSON.parse(dbUser.insightsCachedText);
|
|
const legacyPayload = isLegacyInsightPayload(cached);
|
|
annotate({
|
|
action: { name: "insights.generate.read" },
|
|
meta: { cached: true, legacyPayload, revalidating },
|
|
});
|
|
return apiSuccess({
|
|
insights: cached,
|
|
cached: true,
|
|
cachedAt,
|
|
legacyPayload,
|
|
// Honest stale-serve marker: true while the out-of-band warm is
|
|
// in flight, so the client can poll (bounded) until the fresh
|
|
// briefing lands instead of sitting on the stale payload for the
|
|
// rest of the session.
|
|
revalidating,
|
|
});
|
|
} catch {
|
|
// Invalid cache row — fall through to the empty payload below. The
|
|
// warm enqueue above (when a provider exists) repairs it for the
|
|
// next read.
|
|
}
|
|
}
|
|
|
|
annotate({
|
|
action: { name: "insights.generate.read" },
|
|
meta: { cached: false, revalidating },
|
|
});
|
|
return apiSuccess({
|
|
insights: null,
|
|
cached: false,
|
|
legacyPayload: false,
|
|
revalidating,
|
|
});
|
|
});
|
|
|
|
export const POST = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
// v1.4.31 — the advisor + daily briefing surfaces ride this
|
|
// endpoint. Operator disables Coach, every advisor consumer
|
|
// (briefing card, recommendations grid, hero strip narration)
|
|
// empties out.
|
|
await requireAssistantSurface("coach");
|
|
const userId = user.id;
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
insightsPrivacyMode: true,
|
|
insightsCachedAt: true,
|
|
insightsCachedText: true,
|
|
// v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach
|
|
// settings. Filtered off `features` before serialisation so the
|
|
// LLM never sees the excluded blocks.
|
|
insightsExcludeMetrics: true,
|
|
locale: true,
|
|
// v1.10.0 — profile for the derived-signal briefing detector.
|
|
dateOfBirth: true,
|
|
gender: true,
|
|
heightCm: true,
|
|
},
|
|
});
|
|
|
|
const locale = await resolveServerLocale({
|
|
request,
|
|
userLocale: dbUser?.locale ?? user.locale ?? null,
|
|
});
|
|
|
|
const { data: body, error: jsonError } = await safeJson<{
|
|
force?: unknown;
|
|
}>(request, { maxBytes: 16 * 1024 });
|
|
if (jsonError) return jsonError;
|
|
const forceRefresh = body.force === true;
|
|
|
|
if (
|
|
!forceRefresh &&
|
|
dbUser?.insightsCachedAt &&
|
|
dbUser.insightsCachedText &&
|
|
Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000
|
|
) {
|
|
try {
|
|
const cached = JSON.parse(dbUser.insightsCachedText);
|
|
// v1.4.16 B5c: detect legacy payloads (pre-rationale) so the UI
|
|
// can show a "regenerate for new explainability features" CTA.
|
|
// We don't auto-regenerate — that would burn rate-limit tokens
|
|
// on a cache-hit silently. User-initiated only.
|
|
const legacyPayload = isLegacyInsightPayload(cached);
|
|
annotate({
|
|
action: { name: "insights.generate" },
|
|
meta: { cached: true, legacyPayload },
|
|
});
|
|
return apiSuccess({
|
|
insights: cached,
|
|
cached: true,
|
|
cachedAt: dbUser.insightsCachedAt,
|
|
legacyPayload,
|
|
});
|
|
} catch {
|
|
// Invalid cache, regenerate
|
|
}
|
|
}
|
|
|
|
// P13: rate-limit check moved BELOW the cache return so a hit on the
|
|
// 24h cache never burns one of the LLM-generation tokens. Otherwise a
|
|
// noisy reload loop on the dashboard would lock out real refreshes
|
|
// for an hour for no benefit.
|
|
//
|
|
// v1.4.16 A7: limit raised from 2 → 10 per hour (env-configurable via
|
|
// `INSIGHTS_RATE_LIMIT_PER_HOUR`). The 2/hour limit triggered too
|
|
// aggressively when a user iterated on settings or regenerated after
|
|
// adding a few measurements; 10/hour still cleanly bounds cost while
|
|
// staying out of the way for legitimate use.
|
|
const limit = resolveInsightsRateLimit();
|
|
const rl = await checkRateLimit(`insights:${userId}`, limit, 60 * 60 * 1000);
|
|
if (!rl.allowed) {
|
|
return apiError(`Maximum ${limit} insight generations per hour.`, 429);
|
|
}
|
|
|
|
// v1.4.16 phase B5b: resolve the user's full provider chain so a hard
|
|
// failure on the primary (401, 5xx, network) cascades to the next
|
|
// configured fallback rather than surfacing a 503/422 to the
|
|
// dashboard. Falls back to the single-provider resolution when the
|
|
// chain comes back empty AND the legacy `aiProvider` field still
|
|
// points at something usable — preserving v1.4.15 behaviour for
|
|
// accounts that haven't customised the chain.
|
|
const chain = await resolveProviderChain(userId);
|
|
if (chain.length === 0) {
|
|
// Try the legacy single-provider path one more time so users who
|
|
// configured AI before v1.4.16 don't suddenly see the 422.
|
|
const legacy = await resolveProvider(userId);
|
|
if (legacy.type === "none") {
|
|
return apiError(
|
|
"No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
|
|
422,
|
|
);
|
|
}
|
|
chain.push({ providerType: "admin-openai", instance: legacy });
|
|
}
|
|
|
|
// v1.12.1 — consent gate before any server-managed external egress.
|
|
// Required for the operator's global key (`admin-openai`); BYOK / local /
|
|
// ChatGPT-OAuth chains stay ungated. Throws ConsentRequiredError →
|
|
// apiHandler returns 403 + `consent.ai.required`.
|
|
await assertConsentForChain({ userId, chain, surface: "insights" });
|
|
|
|
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
|
// v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the
|
|
// serialised payload. If the raw (bucketed) shape ever blows past
|
|
// it (regression watch — the v1.4.35 rawMeasurements shape hit
|
|
// 25.9 MB on the largest tenant), the helper throws
|
|
// `FeaturesPayloadTooLargeError`. We downgrade to the aggregated
|
|
// shape rather than 500-ing so the user still gets an insight; the
|
|
// annotate event surfaces the regression to ops via the Logflare
|
|
// pipeline.
|
|
let features: Awaited<ReturnType<typeof extractFeatures>>;
|
|
let payloadDowngraded = false;
|
|
let payloadOversizeBytes: number | null = null;
|
|
// v1.4.36 QA H1 — extra fallback when even the aggregated shape
|
|
// crosses the 5 MB ceiling (very large medication history + multi-
|
|
// year context). Strips anthropometrics + medications + every other
|
|
// exclude-token-mapped block and tries one more time. On a third
|
|
// failure we return 422 with an annotate event for ops visibility
|
|
// rather than 500-ing out of an uncaught throw.
|
|
const MAX_DOWNGRADE_TOKENS: ReadonlyArray<string> = [
|
|
"anthropometrics",
|
|
"medications",
|
|
"compliance",
|
|
"sleep",
|
|
"steps",
|
|
"hrv",
|
|
"resting_hr",
|
|
];
|
|
let payloadHardDowngraded = false;
|
|
try {
|
|
features = await extractFeatures(userId, includeRaw);
|
|
} catch (err) {
|
|
if (err instanceof FeaturesPayloadTooLargeError) {
|
|
payloadDowngraded = true;
|
|
payloadOversizeBytes = err.sizeBytes;
|
|
try {
|
|
features = await extractFeatures(userId, false);
|
|
} catch (retryErr) {
|
|
if (retryErr instanceof FeaturesPayloadTooLargeError) {
|
|
// Aggregated shape ALSO crossed the ceiling. Drop optional
|
|
// context blocks (anthropometrics, medications, sleep,
|
|
// steps, hrv, resting_hr) via the existing exclude filter
|
|
// and try once more.
|
|
payloadHardDowngraded = true;
|
|
payloadOversizeBytes = retryErr.sizeBytes;
|
|
try {
|
|
const aggregated = await extractFeatures(userId, false);
|
|
features = applyInsightsExcludeFilter(
|
|
aggregated,
|
|
MAX_DOWNGRADE_TOKENS,
|
|
);
|
|
} catch (finalErr) {
|
|
// Even the aggregated read itself blew up. Annotate and
|
|
// return 422 rather than let the uncaught throw 500 out.
|
|
annotate({
|
|
meta: {
|
|
insights_payload_too_large: true,
|
|
insights_payload_oversize_bytes:
|
|
finalErr instanceof FeaturesPayloadTooLargeError
|
|
? finalErr.sizeBytes
|
|
: payloadOversizeBytes,
|
|
},
|
|
});
|
|
return apiError(
|
|
"Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.",
|
|
422,
|
|
);
|
|
}
|
|
} else {
|
|
throw retryErr;
|
|
}
|
|
}
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
// v1.4.36 W3 T3 — apply the user's exclude-metrics opt-out list
|
|
// BEFORE serialisation so the model never sees the dropped blocks.
|
|
const excludeList = dbUser?.insightsExcludeMetrics ?? [];
|
|
features = applyInsightsExcludeFilter(features, excludeList);
|
|
// v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries
|
|
// labelled-empty sections (`"sleep": []`, `"medications": []`,
|
|
// etc.). Prevents the model from narrating "there are no
|
|
// medications in your data" when the user explicitly excluded the
|
|
// block.
|
|
const compactFeatures = compactSections(
|
|
features as unknown as Record<string, unknown>,
|
|
);
|
|
const featuresJson = JSON.stringify(compactFeatures, null, 2);
|
|
if (payloadDowngraded) {
|
|
annotate({
|
|
meta: {
|
|
insights_features_downgraded: true,
|
|
insights_features_oversize_bytes: payloadOversizeBytes,
|
|
insights_features_hard_downgraded: payloadHardDowngraded,
|
|
},
|
|
});
|
|
}
|
|
|
|
// v1.4.16 phase B8 — pick up the user's persisted comparison toggle
|
|
// and, when active, build a compact prior-period snapshot from the
|
|
// `features` summaries that already include `avg30LastMonth` /
|
|
// `avg30LastYear` (added in the analytics summarize() helper). The
|
|
// narrative is default-on whenever the toggle is on per research §7
|
|
// Q4 — the pulldown is the single affordance.
|
|
const comparisonSnapshot = await buildComparisonSnapshotForUser(userId);
|
|
// v1.4.25 W4d — GLP-1 plateau detector. Appends a SYSTEM CONTEXT
|
|
// block to the user prompt when the active GLP-1 medication has been
|
|
// on a stable dose for ≥21 days with weight delta within ±0.5 kg.
|
|
// The block instructs the model to emit a glp1_plateau keyFinding
|
|
// framed observationally (no dose recommendation per GROUND RULE 13).
|
|
// Returns null when no GLP-1 is active OR no plateau condition;
|
|
// 99% of users land in the null branch and pay zero token cost.
|
|
const plateauContext = await detectGlp1Plateau(userId);
|
|
let userPrompt = buildUserPrompt(
|
|
featuresJson,
|
|
dbUser?.insightsPrivacyMode ?? "aggregated",
|
|
locale,
|
|
comparisonSnapshot ?? undefined,
|
|
);
|
|
if (plateauContext) {
|
|
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
|
|
}
|
|
// v1.15.20 — fold the user-authored "about me" self-description
|
|
// (Settings → AI) into the briefing as a delimited, user-provided
|
|
// SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing.
|
|
const aboutMe = await getSelfContextTextForUser(userId, locale);
|
|
if (aboutMe) {
|
|
userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
|
|
}
|
|
// v1.10.0 — fold a notable derived wellness signal (readiness / recovery
|
|
// shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block.
|
|
// Reads the one derived contract; null (the common case) costs nothing.
|
|
const derivedSex =
|
|
dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE"
|
|
? (dbUser.gender as "MALE" | "FEMALE")
|
|
: null;
|
|
const derivedBriefing = await detectDerivedBriefingSignals(userId, {
|
|
ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null),
|
|
sex: derivedSex,
|
|
heightCm: dbUser?.heightCm ?? null,
|
|
});
|
|
if (derivedBriefing) {
|
|
userPrompt += buildDerivedBriefingPrompt(derivedBriefing, 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
|
|
// prompt unchanged when no applicable metric section is present.
|
|
const referenceMetrics = metricsFromPresentSections({
|
|
bloodPressure: features.bloodPressure != null,
|
|
weight: features.weight != null,
|
|
pulse: features.pulse != null,
|
|
mood: features.mood != null,
|
|
medication: (features.medications?.length ?? 0) > 0,
|
|
});
|
|
|
|
let result;
|
|
let workingProviderType: string;
|
|
let fallbackHopCount = 0;
|
|
try {
|
|
const fallback = await runRawCompletionWithFallback({
|
|
userId,
|
|
providers: chain,
|
|
params: {
|
|
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
|
|
// — emit a top-level `dailyBriefing` block when the snapshot has
|
|
// analysable signal — plus the trendAnnotations and
|
|
// storyboardAnnotations rules. The legacy `getInsightsSystemPrompt`
|
|
// returned the v1.4.5 `{changed, stable, drivers, …}` shape and
|
|
// never asked the model for dailyBriefing, so the hero strip's
|
|
// "Re-run analysis" button kept producing a payload with no
|
|
// briefing block (Issue 1, v1.4.20 post-deploy). The route already
|
|
// tolerates the strict shape via `insightResultSchema.safeParse`'s
|
|
// soft fallback to `parsed` and `passthrough()` on the strict
|
|
// schema, so switching the prompt does not break legacy callers.
|
|
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
|
|
userPrompt,
|
|
temperature: 0.3,
|
|
maxTokens: 1500,
|
|
},
|
|
});
|
|
result = fallback.result;
|
|
workingProviderType = fallback.workingProvider.providerType;
|
|
fallbackHopCount = fallback.fallbackHops.length;
|
|
} catch (e) {
|
|
if (e instanceof AllProvidersFailedError) {
|
|
annotate({
|
|
meta: {
|
|
insights_chain_outcome: "all-failed",
|
|
insights_chain_attempts: e.attempts.length,
|
|
insights_chain_first_provider: e.attempts[0]?.providerType ?? null,
|
|
insights_chain_first_status: e.attempts[0]?.httpStatus ?? null,
|
|
},
|
|
});
|
|
// Pick the most specific user-facing message we can. Auth-class
|
|
// failures across the entire chain mean every provider's
|
|
// credential is bad — keep the v1.4.5 "check your API key"
|
|
// wording so the user knows where to act. 429 across the chain
|
|
// is rate-limit; 5xx is upstream brown-out; transport-only
|
|
// (ECONNRESET, no httpStatus) maps to the generic connection
|
|
// hint so users on flaky networks don't see "rate-limited".
|
|
const allAuth = e.attempts.every(
|
|
(a) => a.httpStatus === 401 || a.httpStatus === 403,
|
|
);
|
|
if (allAuth) {
|
|
return apiError(
|
|
"AI provider rejected the request — check your API key in Settings > AI",
|
|
422,
|
|
);
|
|
}
|
|
const all429 = e.attempts.every((a) => a.httpStatus === 429);
|
|
if (all429) {
|
|
return apiError("AI provider rate-limited the request", 429);
|
|
}
|
|
const has5xx = e.attempts.some(
|
|
(a) => a.httpStatus !== null && a.httpStatus >= 500,
|
|
);
|
|
if (has5xx) {
|
|
return apiError(
|
|
"AI provider temporarily unavailable, try again in a moment",
|
|
503,
|
|
);
|
|
}
|
|
const allTransport = e.attempts.every(
|
|
(a) => a.httpStatus === null || a.httpStatus <= 0,
|
|
);
|
|
if (allTransport) {
|
|
return apiError(
|
|
"AI provider connection failed — check your AI settings",
|
|
422,
|
|
);
|
|
}
|
|
return apiError(
|
|
"AI provider temporarily unavailable, try again in a moment",
|
|
503,
|
|
);
|
|
}
|
|
// v1.4.6 T5 mapped only the parse-error branch from 502→422. Non-
|
|
// hard provider errors (e.g. a custom 4xx from a self-hosted local
|
|
// model) still propagate here. Cloudflare rewrites 5xx to its own
|
|
// HTML error page, which breaks `await res.json()` on the
|
|
// dashboard. Mirror the v1.4.5 ai/test categorisation.
|
|
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
|
annotate({
|
|
meta: {
|
|
insights_provider_error: err.message.slice(0, 500),
|
|
insights_provider_status: err.httpStatus ?? null,
|
|
insights_provider_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null,
|
|
},
|
|
});
|
|
const status = err.httpStatus ?? 0;
|
|
if (status === 401 || status === 403) {
|
|
return apiError(
|
|
"AI provider rejected the request — check your API key in Settings > AI",
|
|
422,
|
|
);
|
|
}
|
|
if (status === 429) {
|
|
return apiError("AI provider rate-limited the request", 429);
|
|
}
|
|
if (status >= 500) {
|
|
return apiError(
|
|
"AI provider temporarily unavailable, try again in a moment",
|
|
503,
|
|
);
|
|
}
|
|
return apiError(
|
|
"AI provider connection failed — check your AI settings",
|
|
422,
|
|
);
|
|
}
|
|
|
|
let insights: InsightResult | Record<string, unknown>;
|
|
try {
|
|
const parsed = JSON.parse(result.content);
|
|
// Try new schema first, fall back to raw parsed if validation fails
|
|
const validated = insightResultSchema.safeParse(parsed);
|
|
insights = validated.success ? validated.data : parsed;
|
|
} catch {
|
|
// Returning 502 here triggers Cloudflare's HTML error rewrite, which
|
|
// breaks `await res.json()` on the client side. 422 stays passthrough
|
|
// so the React Query mutation can read the JSON body and surface a
|
|
// readable message. Same fix pattern as v1.4.5 ai/test.
|
|
return apiError("AI response was not valid JSON", 422);
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
insightsCachedAt: new Date(),
|
|
insightsCachedText: JSON.stringify(insights),
|
|
// v1.16.8 — store the snapshot fingerprint so the nightly / forced
|
|
// regeneration paths can detect "nothing changed" and skip the
|
|
// provider call. The user-initiated POST itself stays un-gated: an
|
|
// explicit regenerate request is honoured even on unchanged data
|
|
// (it is already bounded by the hourly rate limit above). The
|
|
// composite shape MUST match the gate in `comprehensive-generate.ts`,
|
|
// or every off-request warm after a manual regenerate re-pays a
|
|
// full generation on unchanged data. Beyond the features, three
|
|
// prompt inputs that can change with NO data change join in:
|
|
// - aboutMe (Coach remember / Settings → AI edit),
|
|
// - comparisonBaseline (the comparison toggle adds/removes the
|
|
// prior-period context block; `null` snapshot means "none"),
|
|
// - generationLocale (the language the cached text renders in —
|
|
// hashing it makes a locale switch regenerate exactly once, so
|
|
// the briefing follows the reader; named `generationLocale`
|
|
// because the canonicaliser strips `locale` keys as volatile).
|
|
insightsSnapshotHash: hashInsightSnapshot({
|
|
features: compactFeatures,
|
|
aboutMe: aboutMe ?? null,
|
|
comparisonBaseline: comparisonSnapshot?.baseline ?? "none",
|
|
generationLocale: locale,
|
|
}),
|
|
},
|
|
});
|
|
|
|
// v1.16.8 — no blanket per-status eviction here any more (nuking ~45
|
|
// cache rows per manual regenerate deleted every hash baseline and was
|
|
// the source of the post-regenerate card-regeneration storm). Instead,
|
|
// the manual regenerate takes the cards along through the hash-gated
|
|
// queue: every scope the user has data for is enqueued, the worker
|
|
// forces each generator past its same-day cache read, and the content-
|
|
// hash gate regenerates exactly the cards whose data changed while
|
|
// refreshing the unchanged ones for free. This is the user's escape
|
|
// hatch for a card they can SEE is stale — without it the cards had no
|
|
// refresh path until the next nightly warm.
|
|
const refillScopes = await enqueueStatusRefillForUser(
|
|
userId,
|
|
locale === "de" ? "de" : "en",
|
|
);
|
|
annotate({
|
|
action: { name: "insights.generate.cards_refill" },
|
|
meta: { scopes: refillScopes },
|
|
});
|
|
|
|
// v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily
|
|
// briefing read-only; drop it so the next snapshot carries the fresh
|
|
// briefing instead of the stale one.
|
|
invalidateUserInsights(userId);
|
|
|
|
await auditLog("insights.generate", {
|
|
userId,
|
|
ipAddress: getClientIp(request),
|
|
details: {
|
|
privacyMode: dbUser?.insightsPrivacyMode,
|
|
tokensUsed: result.tokensUsed,
|
|
providerType: result.providerType,
|
|
chainProviderType: workingProviderType,
|
|
fallbackHopCount,
|
|
model: result.model,
|
|
},
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "insights.generate" },
|
|
meta: {
|
|
cached: false,
|
|
providerType: result.providerType,
|
|
chainProviderType: workingProviderType,
|
|
fallbackHopCount,
|
|
model: result.model,
|
|
tokensUsed: result.tokensUsed,
|
|
},
|
|
});
|
|
|
|
return apiSuccess({ insights, cached: false, legacyPayload: false });
|
|
});
|