327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { getDatabase } from "@/lib/database";
|
|
import { buildAIContext } from "@/lib/ai/ai-context";
|
|
import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder";
|
|
import log from "@/lib/logger";
|
|
|
|
export async function POST(req: Request) {
|
|
try {
|
|
const { userId, useExternalModel, modelProvider } = await req.json();
|
|
|
|
if (!userId) {
|
|
return NextResponse.json(
|
|
{ error: "User ID is required" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
log.debug("Generating recommendation for user", {
|
|
userId,
|
|
modelProvider,
|
|
useExternalModel,
|
|
});
|
|
|
|
const db = await getDatabase();
|
|
|
|
// Fetch fitness profile
|
|
const profile = await db.getFitnessProfileByUserId(userId);
|
|
|
|
if (!profile) {
|
|
log.error("Fitness profile not found", undefined, { userId });
|
|
return NextResponse.json(
|
|
{ error: "Fitness profile not found for this user" },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
log.debug("Fitness profile found", { profileId: profile.id });
|
|
|
|
// Build AI context with goals and recommendations
|
|
let prompt: string;
|
|
try {
|
|
const context = await buildAIContext(userId);
|
|
prompt = buildEnhancedPrompt(context);
|
|
log.debug("Using enhanced AI context with goals and history");
|
|
} catch (error) {
|
|
// Fallback to basic prompt if context building fails
|
|
log.warn("Failed to build AI context, using basic prompt", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
prompt = buildBasicPrompt(profile);
|
|
}
|
|
|
|
let parsedResponse;
|
|
|
|
// Determine which AI provider to use
|
|
const provider =
|
|
modelProvider || (useExternalModel ? "deepseek" : "ollama");
|
|
|
|
if (provider === "openai") {
|
|
// Use OpenAI
|
|
const openaiApiKey = process.env.OPENAI_API_KEY;
|
|
|
|
if (!openaiApiKey) {
|
|
return NextResponse.json(
|
|
{ error: "OpenAI API key not configured" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
log.debug("Using OpenAI model", { userId });
|
|
|
|
const openaiResponse = await fetch(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${openaiApiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: "gpt-4o-mini",
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content:
|
|
'You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks. The response must have this exact structure: {"recommendationText": "string", "activityPlan": "string", "dietPlan": "string"}',
|
|
},
|
|
{
|
|
role: "user",
|
|
content: prompt,
|
|
},
|
|
],
|
|
temperature: 0.7,
|
|
max_tokens: 1500,
|
|
response_format: { type: "json_object" },
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!openaiResponse.ok) {
|
|
const errorText = await openaiResponse.text();
|
|
log.error("OpenAI API request failed", new Error(errorText), {
|
|
status: openaiResponse.status,
|
|
});
|
|
return NextResponse.json(
|
|
{ error: "Failed to generate recommendation from OpenAI" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
const openaiData = await openaiResponse.json();
|
|
log.debug("Received OpenAI response", { openaiData });
|
|
|
|
try {
|
|
const content = openaiData.choices[0].message.content;
|
|
parsedResponse = JSON.parse(content);
|
|
} catch (e) {
|
|
log.error("Failed to parse OpenAI response", e, {
|
|
response: openaiData,
|
|
});
|
|
return NextResponse.json(
|
|
{ error: "Invalid response format from OpenAI" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
} else if (provider === "deepseek") {
|
|
// Use DeepSeek AI
|
|
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
|
|
|
if (!deepseekApiKey) {
|
|
return NextResponse.json(
|
|
{ error: "DeepSeek API key not configured" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
log.debug("Using DeepSeek AI model", { userId });
|
|
|
|
const deepseekResponse = await fetch(
|
|
"https://api.deepseek.com/v1/chat/completions",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${deepseekApiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: "deepseek-chat",
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content:
|
|
"You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks.",
|
|
},
|
|
{
|
|
role: "user",
|
|
content: prompt,
|
|
},
|
|
],
|
|
temperature: 0.7,
|
|
max_tokens: 1000,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!deepseekResponse.ok) {
|
|
const errorText = await deepseekResponse.text();
|
|
log.error("DeepSeek API request failed", new Error(errorText), {
|
|
status: deepseekResponse.status,
|
|
});
|
|
return NextResponse.json(
|
|
{ error: "Failed to generate recommendation from DeepSeek AI" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
const deepseekData = await deepseekResponse.json();
|
|
log.debug("Received DeepSeek response", { deepseekData });
|
|
|
|
try {
|
|
const content = deepseekData.choices[0].message.content;
|
|
let cleanResponse = content.trim();
|
|
|
|
// Remove markdown code blocks if present
|
|
if (cleanResponse.startsWith("```json")) {
|
|
cleanResponse = cleanResponse
|
|
.replace(/^```json\s*/, "")
|
|
.replace(/\s*```$/, "");
|
|
} else if (cleanResponse.startsWith("```")) {
|
|
cleanResponse = cleanResponse
|
|
.replace(/^```\s*/, "")
|
|
.replace(/\s*```$/, "");
|
|
}
|
|
|
|
// Find the first '{' and last '}' to extract the JSON object
|
|
const firstBrace = cleanResponse.indexOf("{");
|
|
const lastBrace = cleanResponse.lastIndexOf("}");
|
|
|
|
if (firstBrace !== -1 && lastBrace !== -1) {
|
|
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
|
}
|
|
|
|
parsedResponse = JSON.parse(cleanResponse);
|
|
} catch (e) {
|
|
log.error("Failed to parse DeepSeek response", e, {
|
|
response: deepseekData,
|
|
});
|
|
return NextResponse.json(
|
|
{ error: "Invalid response format from DeepSeek AI" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
} else {
|
|
// Use local Ollama
|
|
log.debug("Using local Ollama model", { userId });
|
|
|
|
const ollamaResponse = await fetch(
|
|
"http://localhost:11434/api/generate",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
model: "gemma3:latest",
|
|
prompt: prompt,
|
|
stream: false,
|
|
format: "json",
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!ollamaResponse.ok) {
|
|
const errorText = await ollamaResponse.text();
|
|
log.error("Ollama API request failed", new Error(errorText), {
|
|
status: ollamaResponse.status,
|
|
});
|
|
return NextResponse.json(
|
|
{ error: "Failed to generate recommendation from Ollama" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
const aiData = await ollamaResponse.json();
|
|
log.debug("Received Ollama response", { response: aiData.response });
|
|
|
|
try {
|
|
let cleanResponse = aiData.response.trim();
|
|
|
|
// Remove markdown code blocks if present
|
|
if (cleanResponse.startsWith("```json")) {
|
|
cleanResponse = cleanResponse
|
|
.replace(/^```json\s*/, "")
|
|
.replace(/\s*```$/, "");
|
|
} else if (cleanResponse.startsWith("```")) {
|
|
cleanResponse = cleanResponse
|
|
.replace(/^```\s*/, "")
|
|
.replace(/\s*```$/, "");
|
|
}
|
|
|
|
// Find the first '{' and last '}' to extract the JSON object
|
|
const firstBrace = cleanResponse.indexOf("{");
|
|
const lastBrace = cleanResponse.lastIndexOf("}");
|
|
|
|
if (firstBrace !== -1 && lastBrace !== -1) {
|
|
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
|
}
|
|
|
|
parsedResponse = JSON.parse(cleanResponse);
|
|
} catch (e) {
|
|
log.error("Failed to parse Ollama response", e, {
|
|
response: aiData.response,
|
|
});
|
|
return NextResponse.json(
|
|
{ error: "Invalid response format from Ollama" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Save to database
|
|
log.debug("Saving recommendation to database", {
|
|
userId,
|
|
profileId: profile.id,
|
|
hasRecommendationText: !!parsedResponse.recommendationText,
|
|
hasActivityPlan: !!parsedResponse.activityPlan,
|
|
hasDietPlan: !!parsedResponse.dietPlan,
|
|
});
|
|
|
|
const recommendation = await db.createRecommendation({
|
|
id: crypto.randomUUID(),
|
|
userId,
|
|
fitnessProfileId: profile.id,
|
|
recommendationText: parsedResponse.recommendationText || "",
|
|
activityPlan: parsedResponse.activityPlan || "",
|
|
dietPlan: parsedResponse.dietPlan || "",
|
|
status: "pending",
|
|
generatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
log.info("Recommendation generated successfully", {
|
|
recommendationId: recommendation.id,
|
|
userId,
|
|
});
|
|
|
|
// Return in standardized format
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: recommendation,
|
|
meta: {
|
|
timestamp: new Date().toISOString(),
|
|
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
log.error("Failed to generate recommendation", error, {
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
errorStack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
return NextResponse.json(
|
|
{ error: "Internal server error" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|