refactor(insights): use provider abstraction in all 7 insight generators

Replace direct OpenAI API calls with resolveProvider() in all insight
generators (general, blood-pressure, weight, pulse, bmi, medication-
compliance, mood). Each generator now uses domain-specific prompts from
@/lib/ai/prompts/ and the AIProvider.generateCompletion() interface.
hasKey -> hasProvider in return signatures and all UI consumers.
Remove openaiKeyEncrypted references from data reset routes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 00:42:46 +02:00
1 parent cd8508b6ad
commit 5a71f9b36a
15 files changed
+217 -791

No files matched your search

-1
View File
@@ -51,7 +51,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
heightCm: null,
dateOfBirth: null,
gender: null,
openaiKeyEncrypted: null,
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
+2 -2
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -32,7 +33,6 @@ export const GET = apiHandler(async () => {
select: {
heightCm: true,
dateOfBirth: true,
openaiKeyEncrypted: true,
},
});
@@ -371,7 +371,7 @@ export const GET = apiHandler(async () => {
moodPulseScatterData,
medications: medCompliance,
alerts,
hasOpenAiKey: !!dbUser?.openaiKeyEncrypted,
hasProvider: (await resolveProvider(userId)).type !== "none",
dataSpanDays,
totalMeasurements: allMeasurements.length,
});
-1
View File
@@ -71,7 +71,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
heightCm: null,
dateOfBirth: null,
gender: null,
openaiKeyEncrypted: null,
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
+14 -14
View File
@@ -130,49 +130,49 @@ interface AnalyticsData {
}
interface GeneralStatusData {
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
interface BloodPressureStatusData {
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
interface WeightStatusData {
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
interface PulseStatusData {
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
interface BmiStatusData {
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
interface MoodStatusData {
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
interface MedicationComplianceStatusData {
hasKey: boolean;
hasProvider: boolean;
summary: string | null;
medications: Array<{
medicationId: string;
@@ -941,7 +941,7 @@ export default function InsightsPage() {
<p className="text-muted-foreground text-sm leading-7">
{generalStatus.text}
</p>
) : !generalStatus?.hasKey ? (
) : !generalStatus?.hasProvider ? (
<p className="text-muted-foreground text-sm">
{t("insights.generalStatusNoKey")}
</p>
@@ -1229,7 +1229,7 @@ export default function InsightsPage() {
<p className="text-muted-foreground text-sm leading-7">
{bloodPressureStatus.text}
</p>
) : !bloodPressureStatus?.hasKey ? (
) : !bloodPressureStatus?.hasProvider ? (
<p className="text-muted-foreground text-sm">
{t("insights.bloodPressureStatusNoKey")}
</p>
@@ -1478,7 +1478,7 @@ export default function InsightsPage() {
<p className="text-muted-foreground text-sm leading-7">
{weightStatus.text}
</p>
) : !weightStatus?.hasKey ? (
) : !weightStatus?.hasProvider ? (
<p className="text-muted-foreground text-sm">
{t("insights.weightStatusNoKey")}
</p>
@@ -1528,7 +1528,7 @@ export default function InsightsPage() {
<p className="text-muted-foreground text-sm leading-7">
{pulseStatus.text}
</p>
) : !pulseStatus?.hasKey ? (
) : !pulseStatus?.hasProvider ? (
<p className="text-muted-foreground text-sm">
{t("insights.pulseStatusNoKey")}
</p>
@@ -1573,7 +1573,7 @@ export default function InsightsPage() {
<p className="text-muted-foreground text-sm leading-7">
{moodStatus.text}
</p>
) : !moodStatus?.hasKey ? (
) : !moodStatus?.hasProvider ? (
<p className="text-muted-foreground text-sm">
{t("insights.moodStatusNoKey")}
</p>
@@ -1684,7 +1684,7 @@ export default function InsightsPage() {
<p className="text-muted-foreground text-sm leading-7">
{medicationComplianceStatus.summary}
</p>
) : !medicationComplianceStatus?.hasKey ? (
) : !medicationComplianceStatus?.hasProvider ? (
<p className="text-muted-foreground text-sm">
{t("insights.medicationComplianceStatusNoKey")}
</p>
@@ -1740,7 +1740,7 @@ export default function InsightsPage() {
<p className="text-muted-foreground text-sm leading-7">
{bmiStatus.text}
</p>
) : !bmiStatus?.hasKey ? (
) : !bmiStatus?.hasProvider ? (
<p className="text-muted-foreground text-sm">
{t("insights.bmiStatusNoKey")}
</p>
+8 -7
View File
@@ -2640,7 +2640,8 @@ function InsightsSettingsSection({
if (!res.ok) return null;
const json = await res.json();
return json.data as {
hasKey: boolean;
codexStatus: string;
hasAdminKey: boolean;
privacyMode: string;
lastInsightAt: string | null;
};
@@ -2730,7 +2731,7 @@ function InsightsSettingsSection({
<h2 className="text-lg font-semibold">{t("settings.kiInsights")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{settings?.hasKey && (
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
</Badge>
@@ -2775,7 +2776,7 @@ function InsightsSettingsSection({
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
settings?.hasKey ? t("settings.apiKeyStored") : "sk-..."
(settings?.codexStatus === "connected" || settings?.hasAdminKey) ? t("settings.apiKeyStored") : "sk-..."
}
className="flex-1"
/>
@@ -2785,9 +2786,9 @@ function InsightsSettingsSection({
) : (
<Key className="mr-1 h-4 w-4" />
)}
{settings?.hasKey ? t("common.replace") : t("common.save")}
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) ? t("common.replace") : t("common.save")}
</Button>
{settings?.hasKey && (
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) && (
<Button
variant="ghost"
type="button"
@@ -2813,7 +2814,7 @@ function InsightsSettingsSection({
)}
{/* Privacy Mode */}
{settings?.hasKey && (
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center justify-between gap-4">
<div className="pr-2">
@@ -2840,7 +2841,7 @@ function InsightsSettingsSection({
)}
{/* Regenerate Reports */}
{settings?.hasKey && (
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) && (
<Button
variant="outline"
onClick={handleRegenerate}
+3 -2
View File
@@ -44,7 +44,8 @@ export function InsightsCard() {
if (!res.ok) return null;
const json = await res.json();
return json.data as {
hasKey: boolean;
codexStatus: string;
hasAdminKey: boolean;
privacyMode: string;
lastInsightAt: string | null;
};
@@ -73,7 +74,7 @@ export function InsightsCard() {
});
// Don't show if no API key configured
if (!settings?.hasKey) return null;
if (!(settings?.codexStatus === "connected" || settings?.hasAdminKey)) return null;
const insights = generate.data?.insights;
+21
View File
@@ -0,0 +1,21 @@
import { BASE_SYSTEM_PROMPT } from "./base-system";
export function getMoodSystemPrompt(): string {
return `${BASE_SYSTEM_PROMPT}
FACHSPEZIFISCH -- STIMMUNG/WOHLBEFINDEN:
- Stimmungsskala: 1 (sehr schlecht) bis 5 (sehr gut), Tagesmittelwerte.
- Trend-Stabilitat: Schwankungen < 0.5 Punkte uber 7 Tage sind normal.
- Anhaltende Phasen: Mehr als 3 Tage unter 2.5 oder uber 4.5 sind auffallig.
- Zusammenhange: Stimmung korreliert haufig mit Schlaf, Aktivitat, Blutdruck und Medikamenten-Compliance.
- Tags: Falls Stimmungs-Tags vorhanden sind, prufe ob bestimmte Tags mit Stimmungslevels korrelieren.
- Cross-Metrik: Falls Kontext zu Gewicht, Blutdruck oder Puls vorhanden ist und eine auffaellige Korrelation zeigt, kurz erwahnen.
- Erzwinge keine Querverweise wenn kein klares Muster erkennbar ist.`;
}
export function getMoodUserPrompt(snapshotJson: string, todayKey: string): string {
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Stimmungsdaten mit Fokus auf Trend, Stabilitat und Zusammenhange mit anderen Gesundheitsmetriken.
${snapshotJson}`;
}
+27 -108
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveProvider } from "@/lib/ai/provider";
import { getBloodPressureSystemPrompt, getBloodPressureUserPrompt } from "@/lib/ai/prompts/blood-pressure";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
pearsonCorrelation,
@@ -10,7 +11,6 @@ import { getMedicationCategories } from "@/lib/medication-category";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { getNoKeyBloodPressureStatusText } from "@/lib/insights/no-key-fallbacks";
const BLOOD_PRESSURE_STATUS_MODEL = "gpt-4o-mini";
const BLOOD_PRESSURE_STATUS_POINTS = 30;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -109,62 +109,6 @@ function pairDailySeries(
.filter((entry): entry is PairedPoint => entry !== null);
}
function getSystemPrompt(locale: SupportedLocale): string {
if (locale === "en") {
return [
"You are a health trend analyst for a private personal project.",
"Write one compact paragraph with about 7 sentences in English.",
"Focus strictly on blood pressure, associated correlations, and blood pressure medication adherence.",
"Use only the provided snapshot with the latest 30 daily points.",
"Mention clear positive and negative trends and provide a concise evaluation.",
"Weight findings by importance: emphasize severe target deviations and strong correlations much more than weak or absent correlations.",
"If fewer than 5 data points exist, state that insufficient data is available for a qualified assessment. If data is sparse over a long period, still derive rough trends but note limited reliability. If the newest measurement is more than 7 days old, mention the data may not be current.",
"Do not include warnings, disclaimers, or references to AI/model limitations.",
"If mood data is available and shows a notable correlation or pattern, briefly mention it. Do not force mood into the assessment if nothing stands out.",
'Return valid JSON only: {"summary":"..."}',
].join(" ");
}
return [
"Du bist ein Gesundheits-Trendanalyst für ein privates Projekt.",
"Schreibe einen kompakten Fließtext mit ungefähr 7 Sätzen auf Deutsch.",
"Fokussiere strikt Blutdruck, die dazugehörigen Korrelationen und die Einnahmekontinuität von Blutdrucksenkern.",
"Nutze ausschließlich den bereitgestellten Snapshot mit den letzten 30 Tagesmesspunkten.",
"Benenne positive wie negative Tendenzen klar und gib eine kurze Bewertung.",
"Gewichte die Aussagen nach Wichtigkeit: starke Korrelationen und klare Zielabweichungen sollen deutlich stärker betont werden als schwache oder fehlende Zusammenhänge.",
"Wenn weniger als 5 Messpunkte vorliegen, sage dass noch nicht genügend Daten für eine fundierte Aussage vorhanden sind. Bei spärlichen Daten über einen langen Zeitraum leite trotzdem grobe Trends ab, weise aber auf eingeschränkte Belastbarkeit hin. Wenn die neueste Messung älter als 7 Tage ist, erwähne dass die Daten möglicherweise nicht aktuell sind.",
"Keine Warnhinweise, keine Haftungsausschlüsse, keine Hinweise auf KI oder Modellgrenzen.",
"Falls Stimmungsdaten vorhanden sind und einen bemerkenswerten Zusammenhang zeigen, erwähne dies kurz. Erzwinge keine Stimmungsaussage, wenn nichts auffällt.",
'Gib nur valides JSON zurück: {"summary":"..."}',
].join(" ");
}
function getUserPrompt(
locale: SupportedLocale,
snapshotJson: string,
todayKey: string,
): string {
if (locale === "en") {
return [
`Date: ${todayKey} (Europe/Berlin)`,
`Use the latest ${BLOOD_PRESSURE_STATUS_POINTS} daily points as provided.`,
"If a day contains multiple values, they are already averaged by day.",
"Write a short blood-pressure focused section summary for the UI.",
"",
snapshotJson,
].join("\n");
}
return [
`Datum: ${todayKey} (Europe/Berlin)`,
`Nutze die letzten ${BLOOD_PRESSURE_STATUS_POINTS} Tagesmesspunkte wie bereitgestellt.`,
"Mehrere Messungen pro Tag sind bereits zu Tagesmitteln aggregiert.",
"Erstelle eine kurze UI-Zusammenfassung speziell für den Blutdruck-Abschnitt.",
"",
snapshotJson,
].join("\n");
}
export async function generateBloodPressureStatusForUser(
userId: string,
options?: {
@@ -172,7 +116,7 @@ export async function generateBloodPressureStatusForUser(
force?: boolean;
},
): Promise<{
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
@@ -182,23 +126,23 @@ export async function generateBloodPressureStatusForUser(
const cacheAction = `insights.blood-pressure-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
openaiKeyEncrypted: true,
dateOfBirth: true,
},
});
if (!user?.openaiKeyEncrypted) {
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasKey: false,
hasProvider: false,
text: getNoKeyBloodPressureStatusText(locale),
cached: true,
updatedAt: null,
};
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
dateOfBirth: true,
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
@@ -217,7 +161,7 @@ export async function generateBloodPressureStatusForUser(
parsed.text.trim().length > 0
) {
return {
hasKey: true,
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
@@ -270,7 +214,7 @@ export async function generateBloodPressureStatusForUser(
})),
).slice(-BLOOD_PRESSURE_STATUS_POINTS);
const bpTargets = getBpTargets(user.dateOfBirth ?? null);
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
const pairedBloodPressure = pairDailySeries(sysSeries, diaSeries).map(
(entry) => ({
day: toBerlinDayKey(entry.date),
@@ -517,43 +461,17 @@ export async function generateBloodPressureStatusForUser(
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
const apiKey = decrypt(user.openaiKeyEncrypted);
const openaiResponse = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: BLOOD_PRESSURE_STATUS_MODEL,
messages: [
{ role: "system", content: getSystemPrompt(locale) },
{
role: "user",
content: getUserPrompt(locale, snapshotJson, todayKey),
},
],
temperature: 0.3,
max_tokens: 550,
response_format: { type: "json_object" },
}),
},
);
const result = await provider.generateCompletion({
systemPrompt: getBloodPressureSystemPrompt(),
userPrompt: getBloodPressureUserPrompt(snapshotJson, todayKey),
temperature: 0.3,
maxTokens: 1000,
});
if (!openaiResponse.ok) {
const body = await openaiResponse.text();
throw new Error(
`OpenAI blood-pressure-status failed (${openaiResponse.status}): ${body}`,
);
}
const openaiJson = await openaiResponse.json();
const content = openaiJson.choices?.[0]?.message?.content;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("OpenAI returned empty content for blood-pressure-status");
throw new Error("AI returned empty content for blood-pressure-status");
}
let summary = "";
@@ -583,16 +501,17 @@ export async function generateBloodPressureStatusForUser(
dateKey: todayKey,
locale,
text: summary,
model: BLOOD_PRESSURE_STATUS_MODEL,
providerType: provider.type,
model: result.model ?? "unknown",
pointsPerMetric: BLOOD_PRESSURE_STATUS_POINTS,
tokensUsed: openaiJson.usage?.total_tokens ?? null,
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
});
return {
hasKey: true,
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
+28 -109
View File
@@ -1,9 +1,9 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveProvider } from "@/lib/ai/provider";
import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi";
import { classifyBMI } from "@/lib/analytics/classifications";
import { getNoKeyBmiStatusText } from "@/lib/insights/no-key-fallbacks";
const BMI_STATUS_MODEL = "gpt-4o-mini";
const BMI_STATUS_POINTS = 30;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -83,62 +83,6 @@ function summarizeSeries(series: Array<{ value: number }>) {
};
}
function getSystemPrompt(locale: SupportedLocale): string {
if (locale === "en") {
return [
"You are a health trend analyst for a private personal project.",
"Write one compact paragraph with about 7 sentences in English.",
"Focus strictly on BMI development and current trend situation.",
"Use only the provided snapshot with the latest 30 daily points.",
"Prioritize the newest measurement day in your interpretation.",
"Weight findings by importance: clear trend shifts and severe BMI-class deviations should be emphasized more strongly.",
"If fewer than 5 data points exist, state that insufficient data is available for a qualified assessment. If data is sparse over a long period, still derive rough trends but note limited reliability. If the newest measurement is more than 7 days old, mention the data may not be current.",
"Do not include warnings, disclaimers, or references to AI/model limitations.",
'Return valid JSON only: {"summary":"..."}',
].join(" ");
}
return [
"Du bist ein Gesundheits-Trendanalyst für ein privates Projekt.",
"Schreibe einen kompakten Fließtext mit ungefähr 7 Sätzen auf Deutsch.",
"Fokussiere strikt auf die BMI-Entwicklung und die aktuelle Trendsituation.",
"Nutze ausschließlich den bereitgestellten Snapshot mit den letzten 30 Tagesmesspunkten.",
"Priorisiere in der Interpretation den neuesten Messpunkt-Tag.",
"Gewichte Aussagen nach Wichtigkeit: klare Trendwechsel und starke BMI-Abweichungen sollen stärker betont werden.",
"Wenn weniger als 5 Messpunkte vorliegen, sage dass noch nicht genügend Daten für eine fundierte Aussage vorhanden sind. Bei spärlichen Daten über einen langen Zeitraum leite trotzdem grobe Trends ab, weise aber auf eingeschränkte Belastbarkeit hin. Wenn die neueste Messung älter als 7 Tage ist, erwähne dass die Daten möglicherweise nicht aktuell sind.",
"Keine Warnhinweise, keine Haftungsausschlüsse, keine Hinweise auf KI oder Modellgrenzen.",
'Gib nur valides JSON zurück: {"summary":"..."}',
].join(" ");
}
function getUserPrompt(
locale: SupportedLocale,
snapshotJson: string,
todayKey: string,
): string {
if (locale === "en") {
return [
`Date: ${todayKey} (Europe/Berlin)`,
`Use the latest ${BMI_STATUS_POINTS} daily points as provided.`,
"If a day contains multiple values, they are already averaged by day.",
"Write a short BMI-focused assessment for the UI section.",
"Use the newest daily point as strongest anchor.",
"",
snapshotJson,
].join("\n");
}
return [
`Datum: ${todayKey} (Europe/Berlin)`,
`Nutze die letzten ${BMI_STATUS_POINTS} Tagesmesspunkte wie bereitgestellt.`,
"Mehrere Messungen pro Tag sind bereits zu Tagesmitteln aggregiert.",
"Erstelle eine kurze BMI-fokussierte Einschätzung für den UI-Abschnitt.",
"Nutze den neuesten Tagesmesspunkt als stärksten Anker.",
"",
snapshotJson,
].join("\n");
}
export async function generateBmiStatusForUser(
userId: string,
options?: {
@@ -146,7 +90,7 @@ export async function generateBmiStatusForUser(
force?: boolean;
},
): Promise<{
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
@@ -156,23 +100,23 @@ export async function generateBmiStatusForUser(
const cacheAction = `insights.bmi-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
openaiKeyEncrypted: true,
heightCm: true,
},
});
if (!user?.openaiKeyEncrypted) {
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasKey: false,
hasProvider: false,
text: getNoKeyBmiStatusText(locale),
cached: true,
updatedAt: null,
};
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
heightCm: true,
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
@@ -191,7 +135,7 @@ export async function generateBmiStatusForUser(
parsed.text.trim().length > 0
) {
return {
hasKey: true,
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
@@ -202,9 +146,9 @@ export async function generateBmiStatusForUser(
}
}
if (!user.heightCm || user.heightCm <= 0) {
if (!user?.heightCm || user.heightCm <= 0) {
return {
hasKey: true,
hasProvider: true,
text:
locale === "de"
? "Für die BMI-Einschätzung fehlen aktuell Größenangaben im Profil."
@@ -293,43 +237,17 @@ export async function generateBmiStatusForUser(
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
const apiKey = decrypt(user.openaiKeyEncrypted);
const openaiResponse = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: BMI_STATUS_MODEL,
messages: [
{ role: "system", content: getSystemPrompt(locale) },
{
role: "user",
content: getUserPrompt(locale, snapshotJson, todayKey),
},
],
temperature: 0.3,
max_tokens: 550,
response_format: { type: "json_object" },
}),
},
);
const result = await provider.generateCompletion({
systemPrompt: getBmiSystemPrompt(),
userPrompt: getBmiUserPrompt(snapshotJson, todayKey),
temperature: 0.3,
maxTokens: 1000,
});
if (!openaiResponse.ok) {
const body = await openaiResponse.text();
throw new Error(
`OpenAI bmi-status failed (${openaiResponse.status}): ${body}`,
);
}
const openaiJson = await openaiResponse.json();
const content = openaiJson.choices?.[0]?.message?.content;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("OpenAI returned empty content for bmi-status");
throw new Error("AI returned empty content for bmi-status");
}
let summary = "";
@@ -357,16 +275,17 @@ export async function generateBmiStatusForUser(
dateKey: todayKey,
locale,
text: summary,
model: BMI_STATUS_MODEL,
providerType: provider.type,
model: result.model ?? "unknown",
pointsPerMetric: BMI_STATUS_POINTS,
tokensUsed: openaiJson.usage?.total_tokens ?? null,
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
});
return {
hasKey: true,
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
+27 -106
View File
@@ -1,9 +1,9 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveProvider } from "@/lib/ai/provider";
import { getGeneralStatusSystemPrompt, getGeneralStatusUserPrompt } from "@/lib/ai/prompts/general-status";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
const GENERAL_STATUS_MODEL = "gpt-4o-mini";
const GENERAL_STATUS_POINTS = 30;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -51,60 +51,6 @@ function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function getSystemPrompt(locale: SupportedLocale): string {
if (locale === "en") {
return [
"You are a health trend analyst for a private personal project.",
"Write exactly one compact paragraph with 5-7 sentences in English.",
"Focus on overall state and clearly mention positive and negative trends.",
"Base your summary strictly on the provided data snapshot.",
"Consider the measurement time spans and data density: if too few data points exist for a metric (<5), say that not enough data is available for a qualified assessment. If data is sparse but covers a long period, still try to derive rough trends but note the limited reliability.",
"If the most recent measurement is more than 7 days old, mention that the data is not current.",
"Do not include warnings, disclaimers, or references to AI/model limitations.",
"If mood data is available and shows a notable correlation or pattern, briefly mention it. Do not force mood into the assessment if nothing stands out.",
'Return valid JSON only: {"summary":"..."}',
].join(" ");
}
return [
"Du bist ein Gesundheits-Trendanalyst für ein privates Projekt.",
"Schreibe genau einen kompakten Fließtext mit 5-7 Sätzen auf Deutsch.",
"Fokussiere den allgemeinen Zustand und benenne positive wie negative Tendenzen klar.",
"Nutze ausschließlich den bereitgestellten Datensnapshot.",
"Berücksichtige die Messzeiträume und Datendichte: Wenn zu wenige Messpunkte (<5) für eine Metrik existieren, sage dass noch nicht genügend Daten für eine fundierte Aussage vorliegen. Wenn Daten spärlich sind aber einen langen Zeitraum abdecken, leite trotzdem grobe Trends ab, weise aber auf die eingeschränkte Belastbarkeit hin.",
"Wenn die neueste Messung länger als 7 Tage zurückliegt, erwähne dass die Daten nicht aktuell sind.",
"Keine Warnhinweise, keine Haftungsausschlüsse, keine Hinweise auf KI oder Modellgrenzen.",
"Falls Stimmungsdaten vorhanden sind und einen bemerkenswerten Zusammenhang zeigen, erwähne dies kurz. Erzwinge keine Stimmungsaussage, wenn nichts auffällt.",
'Gib nur valides JSON zurück: {"summary":"..."}',
].join(" ");
}
function getUserPrompt(
locale: SupportedLocale,
snapshotJson: string,
todayKey: string,
): string {
if (locale === "en") {
return [
`Date: ${todayKey} (Europe/Berlin)`,
`Use only the last ${GENERAL_STATUS_POINTS} daily aggregated data points per metric.`,
"If a day contains multiple values, they are already averaged by day.",
"Provide a concise overall status summary.",
"",
snapshotJson,
].join("\n");
}
return [
`Datum: ${todayKey} (Europe/Berlin)`,
`Nutze nur die letzten ${GENERAL_STATUS_POINTS} tagesaggregierten Messpunkte pro Metrik.`,
"Mehrere Messungen pro Tag sind bereits zu Tagesmitteln zusammengefasst.",
"Erstelle eine prägnante Zusammenfassung des allgemeinen Zustands.",
"",
snapshotJson,
].join("\n");
}
function aggregateDailyAverageSeries(
records: Array<{ measuredAt: Date; value: number }>,
) {
@@ -153,7 +99,7 @@ export async function generateGeneralStatusForUser(
force?: boolean;
},
): Promise<{
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
@@ -163,23 +109,23 @@ export async function generateGeneralStatusForUser(
const cacheAction = `insights.general-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
openaiKeyEncrypted: true,
dateOfBirth: true,
},
});
if (!user?.openaiKeyEncrypted) {
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasKey: false,
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
};
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
dateOfBirth: true,
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
@@ -198,7 +144,7 @@ export async function generateGeneralStatusForUser(
parsed.text.trim().length > 0
) {
return {
hasKey: true,
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
@@ -317,7 +263,7 @@ export async function generateGeneralStatusForUser(
)
: null;
const bpTargets = getBpTargets(user.dateOfBirth ?? null);
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
let bpInTargetLast30Days: number | null = null;
if (bpTargets) {
const sysSeries = (measurementSeries.BLOOD_PRESSURE_SYS?.series ?? []).map(
@@ -408,43 +354,17 @@ export async function generateGeneralStatusForUser(
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
const apiKey = decrypt(user.openaiKeyEncrypted);
const openaiResponse = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: GENERAL_STATUS_MODEL,
messages: [
{ role: "system", content: getSystemPrompt(locale) },
{
role: "user",
content: getUserPrompt(locale, snapshotJson, todayKey),
},
],
temperature: 0.3,
max_tokens: 500,
response_format: { type: "json_object" },
}),
},
);
const result = await provider.generateCompletion({
systemPrompt: getGeneralStatusSystemPrompt(),
userPrompt: getGeneralStatusUserPrompt(snapshotJson, todayKey),
temperature: 0.3,
maxTokens: 1000,
});
if (!openaiResponse.ok) {
const body = await openaiResponse.text();
throw new Error(
`OpenAI general-status failed (${openaiResponse.status}): ${body}`,
);
}
const openaiJson = await openaiResponse.json();
const content = openaiJson.choices?.[0]?.message?.content;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("OpenAI returned empty content for general-status");
throw new Error("AI returned empty content for general-status");
}
let summary = "";
@@ -472,16 +392,17 @@ export async function generateGeneralStatusForUser(
dateKey: todayKey,
locale,
text: summary,
model: GENERAL_STATUS_MODEL,
providerType: provider.type,
model: result.model ?? "unknown",
pointsPerMetric: GENERAL_STATUS_POINTS,
tokensUsed: openaiJson.usage?.total_tokens ?? null,
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
});
return {
hasKey: true,
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
+20 -105
View File
@@ -1,11 +1,11 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveProvider } from "@/lib/ai/provider";
import { getMedicationComplianceSystemPrompt, getMedicationComplianceUserPrompt } from "@/lib/ai/prompts/medication-compliance";
import { calculateCompliance } from "@/lib/analytics/compliance";
import { getMedicationCategories } from "@/lib/medication-category";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks";
const MEDICATION_COMPLIANCE_STATUS_MODEL = "gpt-4o-mini";
const MEDICATION_COMPLIANCE_STATUS_POINTS = 30;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -48,60 +48,6 @@ function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function getSystemPrompt(locale: SupportedLocale): string {
if (locale === "en") {
return [
"You are a health trend analyst for a private personal project.",
"Generate medication adherence insights from the provided data only.",
"Return one overall section summary with about 7 sentences and one per-medication summary with 3-4 sentences each.",
"Use only the latest 30 daily points and prioritize the latest day for each medication.",
"Account for medication name, category, dose strength, and adherence details in your interpretation.",
"Weight findings by importance: persistent misses and low adherence should be emphasized much more than minor variance.",
"If fewer than 5 data points exist, state that insufficient data is available for a qualified assessment. If data is sparse over a long period, still derive rough trends but note limited reliability. If the newest measurement is more than 7 days old, mention the data may not be current.",
"Do not include warnings, disclaimers, or references to AI/model limitations.",
'Return valid JSON only: {"summary":"...","medications":[{"medicationId":"...","summary":"..."}]}',
].join(" ");
}
return [
"Du bist ein Gesundheits-Trendanalyst für ein privates Projekt.",
"Erzeuge Einschätzungen zur Medikamenteneinnahmetreue ausschließlich aus den bereitgestellten Daten.",
"Gib eine Gesamtzusammenfassung mit ungefähr 7 Sätzen und pro Medikament eine Zusammenfassung mit 3-4 Sätzen zurück.",
"Nutze nur die letzten 30 Tagesmesspunkte und priorisiere je Medikament den neuesten Tag.",
"Berücksichtige in der Interpretation Medikamentenname, Kategorie, Dosisstärke und Einnahmedetails.",
"Gewichte Aussagen nach Wichtigkeit: dauerhafte Ausfälle und niedrige Treue sollen deutlich stärker betont werden als kleine Schwankungen.",
"Wenn weniger als 5 Messpunkte vorliegen, sage dass noch nicht genügend Daten für eine fundierte Aussage vorhanden sind. Bei spärlichen Daten über einen langen Zeitraum leite trotzdem grobe Trends ab, weise aber auf eingeschränkte Belastbarkeit hin. Wenn die neueste Messung älter als 7 Tage ist, erwähne dass die Daten möglicherweise nicht aktuell sind.",
"Keine Warnhinweise, keine Haftungsausschlüsse, keine Hinweise auf KI oder Modellgrenzen.",
'Gib nur valides JSON zurück: {"summary":"...","medications":[{"medicationId":"...","summary":"..."}]}',
].join(" ");
}
function getUserPrompt(
locale: SupportedLocale,
snapshotJson: string,
todayKey: string,
): string {
if (locale === "en") {
return [
`Date: ${todayKey} (Europe/Berlin)`,
`Use the latest ${MEDICATION_COMPLIANCE_STATUS_POINTS} daily points as provided per medication.`,
"If a day contains multiple events, they are already aggregated.",
"Generate one overall medication-compliance summary and one summary per medication.",
"",
snapshotJson,
].join("\n");
}
return [
`Datum: ${todayKey} (Europe/Berlin)`,
`Nutze die letzten ${MEDICATION_COMPLIANCE_STATUS_POINTS} Tagesmesspunkte pro Medikament wie bereitgestellt.`,
"Mehrere Ereignisse pro Tag sind bereits aggregiert.",
"Erzeuge eine Gesamtzusammenfassung zur Medikamentencompliance und je Medikament eine eigene Zusammenfassung.",
"",
snapshotJson,
].join("\n");
}
export async function generateMedicationComplianceStatusForUser(
userId: string,
options?: {
@@ -109,7 +55,7 @@ export async function generateMedicationComplianceStatusForUser(
force?: boolean;
},
): Promise<{
hasKey: boolean;
hasProvider: boolean;
summary: string | null;
medications: MedicationSummaryItem[];
cached: boolean;
@@ -120,16 +66,10 @@ export async function generateMedicationComplianceStatusForUser(
const cacheAction = `insights.medication-compliance-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
openaiKeyEncrypted: true,
},
});
if (!user?.openaiKeyEncrypted) {
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasKey: false,
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
@@ -158,7 +98,7 @@ export async function generateMedicationComplianceStatusForUser(
Array.isArray(parsed.medications)
) {
return {
hasKey: true,
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications.filter(
(entry): entry is MedicationSummaryItem =>
@@ -183,7 +123,7 @@ export async function generateMedicationComplianceStatusForUser(
if (medications.length === 0) {
return {
hasKey: true,
hasProvider: true,
summary:
locale === "de"
? "Aktuell sind keine aktiven Medikamente hinterlegt."
@@ -350,44 +290,18 @@ export async function generateMedicationComplianceStatusForUser(
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
const apiKey = decrypt(user.openaiKeyEncrypted);
const openaiResponse = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MEDICATION_COMPLIANCE_STATUS_MODEL,
messages: [
{ role: "system", content: getSystemPrompt(locale) },
{
role: "user",
content: getUserPrompt(locale, snapshotJson, todayKey),
},
],
temperature: 0.3,
max_tokens: 900,
response_format: { type: "json_object" },
}),
},
);
const result = await provider.generateCompletion({
systemPrompt: getMedicationComplianceSystemPrompt(),
userPrompt: getMedicationComplianceUserPrompt(snapshotJson, todayKey),
temperature: 0.3,
maxTokens: 1000,
});
if (!openaiResponse.ok) {
const body = await openaiResponse.text();
throw new Error(
`OpenAI medication-compliance-status failed (${openaiResponse.status}): ${body}`,
);
}
const openaiJson = await openaiResponse.json();
const content = openaiJson.choices?.[0]?.message?.content;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error(
"OpenAI returned empty content for medication-compliance-status",
"AI returned empty content for medication-compliance-status",
);
}
@@ -447,16 +361,17 @@ export async function generateMedicationComplianceStatusForUser(
locale,
summary,
medications: medicationSummaries,
model: MEDICATION_COMPLIANCE_STATUS_MODEL,
providerType: provider.type,
model: result.model ?? "unknown",
pointsPerMetric: MEDICATION_COMPLIANCE_STATUS_POINTS,
tokensUsed: openaiJson.usage?.total_tokens ?? null,
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
});
return {
hasKey: true,
hasProvider: true,
summary,
medications: medicationSummaries,
cached: false,
+19 -110
View File
@@ -1,12 +1,12 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveProvider } from "@/lib/ai/provider";
import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood";
import { getNoKeyMoodStatusText } from "@/lib/insights/no-key-fallbacks";
import {
pearsonCorrelation,
type PairedPoint,
} from "@/lib/analytics/correlations";
const MOOD_STATUS_MODEL = "gpt-4o-mini";
const MOOD_STATUS_POINTS = 30;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -126,66 +126,6 @@ function pairDailySeries(
.filter((entry): entry is PairedPoint => entry !== null);
}
function getSystemPrompt(locale: SupportedLocale): string {
if (locale === "en") {
return [
"You are a health trend analyst for a private personal project.",
"Write one compact paragraph with about 7 sentences in English.",
"Focus strictly on mood trends and well-being stability.",
"Use only the provided snapshot with the latest 30 daily points.",
"Prioritize the newest measurement day in your interpretation.",
"Weight findings by importance: clear mood shifts and sustained periods of poor mood should be emphasized more strongly.",
"If fewer than 5 data points exist, state that insufficient data is available for a qualified assessment. If data is sparse over a long period, still derive rough trends but note limited reliability. If the newest measurement is more than 7 days old, mention the data may not be current.",
"If cross-metric context (weight, blood pressure, pulse) is provided and shows a notable correlation with mood, briefly mention it in 1-2 sentences. Do not force cross-metric references if no clear pattern exists.",
"If mood tags are available, consider whether specific tags correlate with mood levels.",
"Do not include warnings, disclaimers, or references to AI/model limitations.",
'Return valid JSON only: {"summary":"..."}',
].join(" ");
}
return [
"Du bist ein Gesundheits-Trendanalyst für ein privates Projekt.",
"Schreibe einen kompakten Fließtext mit ungefähr 7 Sätzen auf Deutsch.",
"Fokussiere strikt auf den Stimmungsverlauf und die Stabilität des Wohlbefindens.",
"Nutze ausschließlich den bereitgestellten Snapshot mit den letzten 30 Tagesmesspunkten.",
"Priorisiere in der Interpretation den neuesten Messpunkt-Tag.",
"Gewichte Aussagen nach Wichtigkeit: klare Stimmungswechsel und anhaltende Phasen schlechter Stimmung sollen stärker betont werden.",
"Wenn weniger als 5 Messpunkte vorliegen, sage dass noch nicht genügend Daten für eine fundierte Aussage vorhanden sind. Bei spärlichen Daten über einen langen Zeitraum leite trotzdem grobe Trends ab, weise aber auf eingeschränkte Belastbarkeit hin. Wenn die neueste Messung älter als 7 Tage ist, erwähne dass die Daten möglicherweise nicht aktuell sind.",
"Falls Kontext zu anderen Gesundheitsmetriken (Gewicht, Blutdruck, Puls) vorhanden ist und eine auffällige Korrelation mit der Stimmung zeigt, erwähne dies kurz in 1-2 Sätzen. Erzwinge keine Querverweise, wenn kein klares Muster erkennbar ist.",
"Falls Stimmungs-Tags vorhanden sind, prüfe ob bestimmte Tags mit Stimmungslevels korrelieren.",
"Keine Warnhinweise, keine Haftungsausschlüsse, keine Hinweise auf KI oder Modellgrenzen.",
'Gib nur valides JSON zurück: {"summary":"..."}',
].join(" ");
}
function getUserPrompt(
locale: SupportedLocale,
snapshotJson: string,
todayKey: string,
): string {
if (locale === "en") {
return [
`Date: ${todayKey} (Europe/Berlin)`,
`Use the latest ${MOOD_STATUS_POINTS} daily points as provided.`,
"If a day contains multiple values, they are already averaged by day.",
"Write a short mood-focused assessment for the UI section.",
"Use the newest daily point as strongest anchor.",
"",
snapshotJson,
].join("\n");
}
return [
`Datum: ${todayKey} (Europe/Berlin)`,
`Nutze die letzten ${MOOD_STATUS_POINTS} Tagesmesspunkte wie bereitgestellt.`,
"Mehrere Messungen pro Tag sind bereits zu Tagesmitteln aggregiert.",
"Erstelle eine kurze stimmungsfokussierte Einschätzung für den UI-Abschnitt.",
"Nutze den neuesten Tagesmesspunkt als stärksten Anker.",
"",
snapshotJson,
].join("\n");
}
export async function generateMoodStatusForUser(
userId: string,
options?: {
@@ -193,7 +133,7 @@ export async function generateMoodStatusForUser(
force?: boolean;
},
): Promise<{
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
@@ -203,16 +143,10 @@ export async function generateMoodStatusForUser(
const cacheAction = `insights.mood-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
openaiKeyEncrypted: true,
},
});
if (!user?.openaiKeyEncrypted) {
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasKey: false,
hasProvider: false,
text: getNoKeyMoodStatusText(locale),
cached: true,
updatedAt: null,
@@ -237,7 +171,7 @@ export async function generateMoodStatusForUser(
parsed.text.trim().length > 0
) {
return {
hasKey: true,
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
@@ -429,43 +363,17 @@ export async function generateMoodStatusForUser(
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
const apiKey = decrypt(user.openaiKeyEncrypted);
const openaiResponse = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MOOD_STATUS_MODEL,
messages: [
{ role: "system", content: getSystemPrompt(locale) },
{
role: "user",
content: getUserPrompt(locale, snapshotJson, todayKey),
},
],
temperature: 0.3,
max_tokens: 550,
response_format: { type: "json_object" },
}),
},
);
const result = await provider.generateCompletion({
systemPrompt: getMoodSystemPrompt(),
userPrompt: getMoodUserPrompt(snapshotJson, todayKey),
temperature: 0.3,
maxTokens: 1000,
});
if (!openaiResponse.ok) {
const body = await openaiResponse.text();
throw new Error(
`OpenAI mood-status failed (${openaiResponse.status}): ${body}`,
);
}
const openaiJson = await openaiResponse.json();
const content = openaiJson.choices?.[0]?.message?.content;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("OpenAI returned empty content for mood-status");
throw new Error("AI returned empty content for mood-status");
}
let summary = "";
@@ -493,16 +401,17 @@ export async function generateMoodStatusForUser(
dateKey: todayKey,
locale,
text: summary,
model: MOOD_STATUS_MODEL,
providerType: provider.type,
model: result.model ?? "unknown",
pointsPerMetric: MOOD_STATUS_POINTS,
tokensUsed: openaiJson.usage?.total_tokens ?? null,
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
});
return {
hasKey: true,
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
+29 -112
View File
@@ -1,12 +1,12 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveProvider } from "@/lib/ai/provider";
import { getPulseSystemPrompt, getPulseUserPrompt } from "@/lib/ai/prompts/pulse";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks";
const PULSE_STATUS_MODEL = "gpt-4o-mini";
const PULSE_STATUS_POINTS = 30;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -86,64 +86,6 @@ function summarizeSeries(series: Array<{ value: number }>) {
};
}
function getSystemPrompt(locale: SupportedLocale): string {
if (locale === "en") {
return [
"You are a health trend analyst for a private personal project.",
"Write one compact paragraph with about 7 sentences in English.",
"Focus strictly on resting pulse trends and target-zone adherence.",
"Use only the provided snapshot with the latest 30 daily points.",
"Prioritize the newest measurement day in your interpretation.",
"Weight findings by importance: clear trend changes and strong target deviations should be emphasized more strongly.",
"If fewer than 5 data points exist, state that insufficient data is available for a qualified assessment. If data is sparse over a long period, still derive rough trends but note limited reliability. If the newest measurement is more than 7 days old, mention the data may not be current.",
"Do not include warnings, disclaimers, or references to AI/model limitations.",
"If mood data is available and shows a notable correlation or pattern, briefly mention it. Do not force mood into the assessment if nothing stands out.",
'Return valid JSON only: {"summary":"..."}',
].join(" ");
}
return [
"Du bist ein Gesundheits-Trendanalyst für ein privates Projekt.",
"Schreibe einen kompakten Fließtext mit ungefähr 7 Sätzen auf Deutsch.",
"Fokussiere strikt auf den Ruhepulsverlauf und die Zielbereichstreue.",
"Nutze ausschließlich den bereitgestellten Snapshot mit den letzten 30 Tagesmesspunkten.",
"Priorisiere in der Interpretation den neuesten Messpunkt-Tag.",
"Gewichte Aussagen nach Wichtigkeit: klare Trendwechsel und deutliche Zielabweichungen sollen stärker betont werden.",
"Wenn weniger als 5 Messpunkte vorliegen, sage dass noch nicht genügend Daten für eine fundierte Aussage vorhanden sind. Bei spärlichen Daten über einen langen Zeitraum leite trotzdem grobe Trends ab, weise aber auf eingeschränkte Belastbarkeit hin. Wenn die neueste Messung älter als 7 Tage ist, erwähne dass die Daten möglicherweise nicht aktuell sind.",
"Keine Warnhinweise, keine Haftungsausschlüsse, keine Hinweise auf KI oder Modellgrenzen.",
"Falls Stimmungsdaten vorhanden sind und einen bemerkenswerten Zusammenhang zeigen, erwähne dies kurz. Erzwinge keine Stimmungsaussage, wenn nichts auffällt.",
'Gib nur valides JSON zurück: {"summary":"..."}',
].join(" ");
}
function getUserPrompt(
locale: SupportedLocale,
snapshotJson: string,
todayKey: string,
): string {
if (locale === "en") {
return [
`Date: ${todayKey} (Europe/Berlin)`,
`Use the latest ${PULSE_STATUS_POINTS} daily points as provided.`,
"If a day contains multiple values, they are already averaged by day.",
"Write a short pulse-focused assessment for the UI section.",
"Use the newest daily point as strongest anchor.",
"",
snapshotJson,
].join("\n");
}
return [
`Datum: ${todayKey} (Europe/Berlin)`,
`Nutze die letzten ${PULSE_STATUS_POINTS} Tagesmesspunkte wie bereitgestellt.`,
"Mehrere Messungen pro Tag sind bereits zu Tagesmitteln aggregiert.",
"Erstelle eine kurze pulsfokussierte Einschätzung für den UI-Abschnitt.",
"Nutze den neuesten Tagesmesspunkt als stärksten Anker.",
"",
snapshotJson,
].join("\n");
}
export async function generatePulseStatusForUser(
userId: string,
options?: {
@@ -151,7 +93,7 @@ export async function generatePulseStatusForUser(
force?: boolean;
},
): Promise<{
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
@@ -161,24 +103,24 @@ export async function generatePulseStatusForUser(
const cacheAction = `insights.pulse-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
openaiKeyEncrypted: true,
dateOfBirth: true,
gender: true,
},
});
if (!user?.openaiKeyEncrypted) {
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasKey: false,
hasProvider: false,
text: getNoKeyPulseStatusText(locale),
cached: true,
updatedAt: null,
};
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
@@ -197,7 +139,7 @@ export async function generatePulseStatusForUser(
parsed.text.trim().length > 0
) {
return {
hasKey: true,
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
@@ -258,10 +200,10 @@ export async function generatePulseStatusForUser(
)
: null;
const pulseAge = getAgeFromDateOfBirth(user.dateOfBirth ?? null);
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
const pulseTarget = getPersonalizedPulseTarget(
pulseAge,
(user.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
);
const inTargetPctLast30DailyPoints =
@@ -343,43 +285,17 @@ export async function generatePulseStatusForUser(
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
const apiKey = decrypt(user.openaiKeyEncrypted);
const openaiResponse = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: PULSE_STATUS_MODEL,
messages: [
{ role: "system", content: getSystemPrompt(locale) },
{
role: "user",
content: getUserPrompt(locale, snapshotJson, todayKey),
},
],
temperature: 0.3,
max_tokens: 550,
response_format: { type: "json_object" },
}),
},
);
const result = await provider.generateCompletion({
systemPrompt: getPulseSystemPrompt(),
userPrompt: getPulseUserPrompt(snapshotJson, todayKey),
temperature: 0.3,
maxTokens: 1000,
});
if (!openaiResponse.ok) {
const body = await openaiResponse.text();
throw new Error(
`OpenAI pulse-status failed (${openaiResponse.status}): ${body}`,
);
}
const openaiJson = await openaiResponse.json();
const content = openaiJson.choices?.[0]?.message?.content;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("OpenAI returned empty content for pulse-status");
throw new Error("AI returned empty content for pulse-status");
}
let summary = "";
@@ -407,16 +323,17 @@ export async function generatePulseStatusForUser(
dateKey: todayKey,
locale,
text: summary,
model: PULSE_STATUS_MODEL,
providerType: provider.type,
model: result.model ?? "unknown",
pointsPerMetric: PULSE_STATUS_POINTS,
tokensUsed: openaiJson.usage?.total_tokens ?? null,
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
});
return {
hasKey: true,
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
+19 -108
View File
@@ -1,12 +1,12 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveProvider } from "@/lib/ai/provider";
import { getWeightSystemPrompt, getWeightUserPrompt } from "@/lib/ai/prompts/weight";
import {
pearsonCorrelation,
type PairedPoint,
} from "@/lib/analytics/correlations";
import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks";
const WEIGHT_STATUS_MODEL = "gpt-4o-mini";
const WEIGHT_STATUS_POINTS = 30;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -105,64 +105,6 @@ function pairDailySeries(
.filter((entry): entry is PairedPoint => entry !== null);
}
function getSystemPrompt(locale: SupportedLocale): string {
if (locale === "en") {
return [
"You are a health trend analyst for a private personal project.",
"Write one compact paragraph with about 7 sentences in English.",
"Focus strictly on weight trends and the relation to blood pressure.",
"Use only the provided snapshot with the latest 30 daily points.",
"Prioritize the newest measurement day in your interpretation.",
"Weight findings by importance: clear trend changes, strong correlations, and major latest-point deviations should be emphasized much more than weak signals.",
"If fewer than 5 data points exist, state that insufficient data is available for a qualified assessment. If data is sparse over a long period, still derive rough trends but note limited reliability. If the newest measurement is more than 7 days old, mention the data may not be current.",
"Do not include warnings, disclaimers, or references to AI/model limitations.",
"If mood data is available and shows a notable correlation or pattern, briefly mention it. Do not force mood into the assessment if nothing stands out.",
'Return valid JSON only: {"summary":"..."}',
].join(" ");
}
return [
"Du bist ein Gesundheits-Trendanalyst für ein privates Projekt.",
"Schreibe einen kompakten Fließtext mit ungefähr 7 Sätzen auf Deutsch.",
"Fokussiere strikt auf Gewichtstrend und die Beziehung zum Blutdruck.",
"Nutze ausschließlich den bereitgestellten Snapshot mit den letzten 30 Tagesmesspunkten.",
"Priorisiere in der Interpretation den neuesten Messpunkt-Tag.",
"Gewichte Aussagen nach Wichtigkeit: klare Trendwechsel, starke Korrelationen und große Abweichungen am neuesten Messpunkt sollen deutlich stärker betont werden als schwache Signale.",
"Wenn weniger als 5 Messpunkte vorliegen, sage dass noch nicht genügend Daten für eine fundierte Aussage vorhanden sind. Bei spärlichen Daten über einen langen Zeitraum leite trotzdem grobe Trends ab, weise aber auf eingeschränkte Belastbarkeit hin. Wenn die neueste Messung älter als 7 Tage ist, erwähne dass die Daten möglicherweise nicht aktuell sind.",
"Keine Warnhinweise, keine Haftungsausschlüsse, keine Hinweise auf KI oder Modellgrenzen.",
"Falls Stimmungsdaten vorhanden sind und einen bemerkenswerten Zusammenhang zeigen, erwähne dies kurz. Erzwinge keine Stimmungsaussage, wenn nichts auffällt.",
'Gib nur valides JSON zurück: {"summary":"..."}',
].join(" ");
}
function getUserPrompt(
locale: SupportedLocale,
snapshotJson: string,
todayKey: string,
): string {
if (locale === "en") {
return [
`Date: ${todayKey} (Europe/Berlin)`,
`Use the latest ${WEIGHT_STATUS_POINTS} daily points as provided.`,
"If a day contains multiple values, they are already averaged by day.",
"Evaluate the weight chart and the weight-vs-blood-pressure chart.",
"Use the newest daily point as strongest anchor for the short assessment.",
"",
snapshotJson,
].join("\n");
}
return [
`Datum: ${todayKey} (Europe/Berlin)`,
`Nutze die letzten ${WEIGHT_STATUS_POINTS} Tagesmesspunkte wie bereitgestellt.`,
"Mehrere Messungen pro Tag sind bereits zu Tagesmitteln aggregiert.",
"Werte den Gewichts-Chart und den Chart Gewicht versus Blutdruck aus.",
"Nutze den neuesten Tagesmesspunkt als stärksten Anker für die kurze Einschätzung.",
"",
snapshotJson,
].join("\n");
}
export async function generateWeightStatusForUser(
userId: string,
options?: {
@@ -170,7 +112,7 @@ export async function generateWeightStatusForUser(
force?: boolean;
},
): Promise<{
hasKey: boolean;
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
@@ -180,16 +122,10 @@ export async function generateWeightStatusForUser(
const cacheAction = `insights.weight-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
openaiKeyEncrypted: true,
},
});
if (!user?.openaiKeyEncrypted) {
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasKey: false,
hasProvider: false,
text: getNoKeyWeightStatusText(locale),
cached: true,
updatedAt: null,
@@ -214,7 +150,7 @@ export async function generateWeightStatusForUser(
parsed.text.trim().length > 0
) {
return {
hasKey: true,
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
@@ -413,43 +349,17 @@ export async function generateWeightStatusForUser(
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
const apiKey = decrypt(user.openaiKeyEncrypted);
const openaiResponse = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: WEIGHT_STATUS_MODEL,
messages: [
{ role: "system", content: getSystemPrompt(locale) },
{
role: "user",
content: getUserPrompt(locale, snapshotJson, todayKey),
},
],
temperature: 0.3,
max_tokens: 550,
response_format: { type: "json_object" },
}),
},
);
const result = await provider.generateCompletion({
systemPrompt: getWeightSystemPrompt(),
userPrompt: getWeightUserPrompt(snapshotJson, todayKey),
temperature: 0.3,
maxTokens: 1000,
});
if (!openaiResponse.ok) {
const body = await openaiResponse.text();
throw new Error(
`OpenAI weight-status failed (${openaiResponse.status}): ${body}`,
);
}
const openaiJson = await openaiResponse.json();
const content = openaiJson.choices?.[0]?.message?.content;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("OpenAI returned empty content for weight-status");
throw new Error("AI returned empty content for weight-status");
}
let summary = "";
@@ -477,16 +387,17 @@ export async function generateWeightStatusForUser(
dateKey: todayKey,
locale,
text: summary,
model: WEIGHT_STATUS_MODEL,
providerType: provider.type,
model: result.model ?? "unknown",
pointsPerMetric: WEIGHT_STATUS_POINTS,
tokensUsed: openaiJson.usage?.total_tokens ?? null,
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
});
return {
hasKey: true,
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
-6
View File
@@ -483,7 +483,6 @@ async function handleGeneralStatusGenerate(jobs: Job<GeneralStatusPayload>[]) {
try {
recordInsightsRun();
const users = await prisma.user.findMany({
where: { openaiKeyEncrypted: { not: null } },
select: { id: true, locale: true },
});
@@ -525,7 +524,6 @@ async function handleBloodPressureStatusGenerate(
const prisma = getWorkerPrisma();
try {
const users = await prisma.user.findMany({
where: { openaiKeyEncrypted: { not: null } },
select: { id: true, locale: true },
});
@@ -565,7 +563,6 @@ async function handleWeightStatusGenerate(jobs: Job<WeightStatusPayload>[]) {
const prisma = getWorkerPrisma();
try {
const users = await prisma.user.findMany({
where: { openaiKeyEncrypted: { not: null } },
select: { id: true, locale: true },
});
@@ -605,7 +602,6 @@ async function handlePulseStatusGenerate(jobs: Job<PulseStatusPayload>[]) {
const prisma = getWorkerPrisma();
try {
const users = await prisma.user.findMany({
where: { openaiKeyEncrypted: { not: null } },
select: { id: true, locale: true },
});
@@ -645,7 +641,6 @@ async function handleBmiStatusGenerate(jobs: Job<BmiStatusPayload>[]) {
const prisma = getWorkerPrisma();
try {
const users = await prisma.user.findMany({
where: { openaiKeyEncrypted: { not: null } },
select: { id: true, locale: true },
});
@@ -687,7 +682,6 @@ async function handleMedicationComplianceStatusGenerate(
const prisma = getWorkerPrisma();
try {
const users = await prisma.user.findMany({
where: { openaiKeyEncrypted: { not: null } },
select: { id: true, locale: true },
});