mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(insights): use provider abstraction in settings and generate routes
Replace direct OpenAI API calls with resolveProvider() abstraction. Settings route now returns codexStatus/codexConnectedAt/hasAdminKey instead of hasKey. Generate route validates AI response with insightResultSchema and logs providerType/model. Also fixes AppSettings lookup id from 1 to "singleton" in provider.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fde3a0941a
commit
249a2c74ba
3 files changed
+46
-115
No files matched your search
@@ -1,130 +1,79 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import {
|
||||
INSIGHTS_SYSTEM_PROMPT,
|
||||
buildUserPrompt,
|
||||
type InsightsOutput,
|
||||
} from "@/lib/insights/prompt";
|
||||
import { INSIGHTS_SYSTEM_PROMPT, buildUserPrompt } from "@/lib/insights/prompt";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Generate AI-powered health insights.
|
||||
* Rate limit: 2 per hour per user.
|
||||
* Caches result daily (or until new data arrives).
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
// Rate limit: 2 per hour per user
|
||||
const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError(
|
||||
"Maximum 2 insight generations per hour. Please try again later.",
|
||||
429,
|
||||
);
|
||||
return apiError("Maximum 2 insight generations per hour.", 429);
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
openaiKeyEncrypted: true,
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser?.openaiKeyEncrypted) {
|
||||
return apiError(
|
||||
"No OpenAI API key configured. Please set it in settings.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if force refresh is requested
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const forceRefresh = body.force === true;
|
||||
|
||||
// Return cached result if available and less than 24h old
|
||||
if (
|
||||
!forceRefresh &&
|
||||
dbUser.insightsCachedAt &&
|
||||
dbUser?.insightsCachedAt &&
|
||||
dbUser.insightsCachedText &&
|
||||
Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000
|
||||
) {
|
||||
try {
|
||||
const cached = JSON.parse(dbUser.insightsCachedText) as InsightsOutput;
|
||||
const cached = JSON.parse(dbUser.insightsCachedText);
|
||||
annotate({ action: { name: "insights.generate" }, meta: { cached: true } });
|
||||
return apiSuccess({
|
||||
insights: cached,
|
||||
cached: true,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
});
|
||||
return apiSuccess({ insights: cached, cached: true, cachedAt: dbUser.insightsCachedAt });
|
||||
} catch {
|
||||
// Invalid cache, regenerate
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = decrypt(dbUser.openaiKeyEncrypted);
|
||||
const includeRaw = dbUser.insightsPrivacyMode === "raw";
|
||||
const provider = await resolveProvider(userId);
|
||||
|
||||
if (provider.type === "none") {
|
||||
return apiError("No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422);
|
||||
}
|
||||
|
||||
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
||||
const features = await extractFeatures(userId, includeRaw);
|
||||
const featuresJson = JSON.stringify(features, null, 2);
|
||||
const userPrompt = buildUserPrompt(featuresJson, dbUser.insightsPrivacyMode);
|
||||
const userPrompt = buildUserPrompt(featuresJson, dbUser?.insightsPrivacyMode ?? "aggregated");
|
||||
|
||||
// Call OpenAI
|
||||
const openaiRes = await fetch(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: INSIGHTS_SYSTEM_PROMPT },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 1000,
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: INSIGHTS_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
});
|
||||
|
||||
if (!openaiRes.ok) {
|
||||
const errBody = await openaiRes.text();
|
||||
annotate({ meta: { openai_status: openaiRes.status, openai_error: errBody } });
|
||||
if (openaiRes.status === 401) {
|
||||
return apiError("Invalid OpenAI API key", 422);
|
||||
}
|
||||
return apiError("OpenAI request failed", 502);
|
||||
}
|
||||
|
||||
const openaiJson = await openaiRes.json();
|
||||
const content = openaiJson.choices?.[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
return apiError("No response from OpenAI", 502);
|
||||
}
|
||||
|
||||
let insights: InsightsOutput;
|
||||
let insights: InsightResult | Record<string, unknown>;
|
||||
try {
|
||||
insights = JSON.parse(content);
|
||||
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 {
|
||||
return apiError("Failed to parse OpenAI response", 502);
|
||||
return apiError("Failed to parse AI response", 502);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
@@ -133,13 +82,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Audit log (no sensitive content)
|
||||
await auditLog("insights.generate", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
privacyMode: dbUser.insightsPrivacyMode,
|
||||
tokensUsed: openaiJson.usage?.total_tokens ?? null,
|
||||
privacyMode: dbUser?.insightsPrivacyMode,
|
||||
tokensUsed: result.tokensUsed,
|
||||
providerType: result.providerType,
|
||||
model: result.model,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -147,8 +97,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "insights.generate" },
|
||||
meta: {
|
||||
cached: false,
|
||||
privacyMode: dbUser.insightsPrivacyMode,
|
||||
tokensUsed: openaiJson.usage?.total_tokens ?? null,
|
||||
providerType: result.providerType,
|
||||
model: result.model,
|
||||
tokensUsed: result.tokensUsed,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,72 +1,52 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Get insights configuration status.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
openaiKeyEncrypted: true,
|
||||
codexConnectionStatus: true,
|
||||
codexConnectedAt: true,
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { adminAiKeyEncrypted: true },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.settings.get" } });
|
||||
|
||||
return apiSuccess({
|
||||
hasKey: !!dbUser?.openaiKeyEncrypted,
|
||||
codexStatus: dbUser?.codexConnectionStatus ?? "disconnected",
|
||||
codexConnectedAt: dbUser?.codexConnectedAt ?? null,
|
||||
hasAdminKey: !!settings?.adminAiKeyEncrypted,
|
||||
privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
lastInsightAt: dbUser?.insightsCachedAt ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update insights settings (API key and/or privacy mode).
|
||||
*/
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
// Update API key if provided
|
||||
if (typeof body.apiKey === "string") {
|
||||
const key = (body.apiKey as string).trim();
|
||||
if (key === "") {
|
||||
// Remove key
|
||||
data.openaiKeyEncrypted = null;
|
||||
data.insightsCachedAt = null;
|
||||
data.insightsCachedText = null;
|
||||
} else {
|
||||
if (!key.startsWith("sk-")) {
|
||||
return apiError(
|
||||
"Invalid API key format (must start with sk-)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
data.openaiKeyEncrypted = encrypt(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Update privacy mode if provided
|
||||
if (typeof body.privacyMode === "string") {
|
||||
const mode = body.privacyMode as string;
|
||||
if (!["aggregated", "raw"].includes(mode)) {
|
||||
return apiError("Invalid privacy mode", 422);
|
||||
}
|
||||
data.insightsPrivacyMode = mode;
|
||||
// Clear cache when privacy mode changes
|
||||
data.insightsCachedAt = null;
|
||||
data.insightsCachedText = null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class NoProvider implements AIProvider {
|
||||
}
|
||||
|
||||
async function resolveAdminProvider(): Promise<AIProvider> {
|
||||
const settings = await prisma.appSettings.findUnique({ where: { id: 1 } });
|
||||
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
|
||||
|
||||
if (settings?.adminAiKeyEncrypted) {
|
||||
return new OpenAIClient({
|
||||
|
||||
Reference in new issue
Block a user