From ef9f39e564672c4f3db008fee4ee391edbb33fef Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 19:43:19 +0200 Subject: [PATCH] add resilient self-plan generation with provider fallback --- .../recommendations/generate-self/route.ts | 241 +++++++++++++----- apps/mobile/src/api/recommendations.ts | 12 +- apps/mobile/src/app/(tabs)/goals.tsx | 2 +- 3 files changed, 193 insertions(+), 62 deletions(-) diff --git a/apps/admin/src/app/api/recommendations/generate-self/route.ts b/apps/admin/src/app/api/recommendations/generate-self/route.ts index 9af6320..3755138 100644 --- a/apps/admin/src/app/api/recommendations/generate-self/route.ts +++ b/apps/admin/src/app/api/recommendations/generate-self/route.ts @@ -22,6 +22,45 @@ interface ParsedPlanItem { | "custom"; } +interface GeneratedPlanContent { + recommendationText?: string; + activityPlan?: string; + dietPlan?: string; +} + +function buildFallbackPlan(profile: { + activityLevel?: string; + fitnessGoals?: string[] | string; + medicalConditions?: string; +}): GeneratedPlanContent { + const goals = Array.isArray(profile.fitnessGoals) + ? profile.fitnessGoals + : typeof profile.fitnessGoals === "string" && profile.fitnessGoals + ? [profile.fitnessGoals] + : ["general fitness"]; + + const primaryGoal = goals[0] || "general fitness"; + const activityLevel = profile.activityLevel || "moderate"; + const hasMedicalNotes = Boolean(profile.medicalConditions?.trim()); + + return { + recommendationText: + `Personalized starter plan focused on ${primaryGoal} with ${activityLevel} activity pacing.` + + (hasMedicalNotes + ? " Medical notes detected, so keep intensity conservative and progress gradually." + : ""), + activityPlan: + "- 3 strength sessions per week (full-body, 35-45 min)\n" + + "- 2 cardio sessions per week (20-30 min brisk walk/run/cycle)\n" + + "- 10 minutes daily mobility/stretching after workouts\n" + + "- 1 full recovery day each week", + dietPlan: + "- Build meals around lean protein, vegetables, whole grains, and hydration\n" + + "- Keep portions consistent and avoid skipping meals\n" + + "- Track intake daily and adjust calories based on weekly progress", + }; +} + function inferGoalType(text: string): ParsedPlanItem["goalType"] { const normalized = text.toLowerCase(); @@ -100,6 +139,126 @@ function getDefaultPlanItems(): ParsedPlanItem[] { })); } +function parseJsonPayload(content: string): GeneratedPlanContent { + let cleanResponse = content.trim(); + + if (cleanResponse.startsWith("```json")) { + cleanResponse = cleanResponse + .replace(/^```json\s*/, "") + .replace(/\s*```$/, ""); + } else if (cleanResponse.startsWith("```")) { + cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, ""); + } + + const firstBrace = cleanResponse.indexOf("{"); + const lastBrace = cleanResponse.lastIndexOf("}"); + if (firstBrace !== -1 && lastBrace !== -1) { + cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1); + } + + return JSON.parse(cleanResponse) as GeneratedPlanContent; +} + +async function generateWithOpenAI( + openaiApiKey: string, + prompt: string, +): Promise { + const response = 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 (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenAI failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return parseJsonPayload(data.choices[0].message.content as string); +} + +async function generateWithDeepSeek( + deepseekApiKey: string, + prompt: string, +): Promise { + const response = 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: 1200, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`DeepSeek failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return parseJsonPayload(data.choices[0].message.content as string); +} + +async function generateWithOllama( + prompt: string, +): Promise { + const response = await fetch("http://localhost:11434/api/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gemma3:latest", + prompt, + stream: false, + format: "json", + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Ollama failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return parseJsonPayload(data.response as string); +} + export async function POST() { try { const { userId } = await auth(); @@ -180,71 +339,32 @@ export async function POST() { } const openaiApiKey = process.env.OPENAI_API_KEY; - if (!openaiApiKey) { - return NextResponse.json( - { error: "AI provider unavailable. Try again later." }, - { status: 503 }, - ); - } + const deepseekApiKey = process.env.DEEPSEEK_API_KEY; - 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 self-generate request failed", new Error(errorText), { - userId, - status: openaiResponse.status, - }); - return NextResponse.json( - { error: "Failed to generate AI plan" }, - { status: 500 }, - ); - } - - const openaiData = await openaiResponse.json(); - - let parsedResponse: { - recommendationText?: string; - activityPlan?: string; - dietPlan?: string; - }; + let parsedResponse: GeneratedPlanContent; + let usedFallbackPlan = false; try { - parsedResponse = JSON.parse(openaiData.choices[0].message.content); - } catch (e) { - log.error("Failed to parse self-generate OpenAI response", e, { + if (openaiApiKey) { + parsedResponse = await generateWithOpenAI(openaiApiKey, prompt); + } else if (deepseekApiKey) { + parsedResponse = await generateWithDeepSeek(deepseekApiKey, prompt); + } else { + parsedResponse = await generateWithOllama(prompt); + } + } catch (providerError) { + log.error("Self-generate provider failed", providerError, { userId, + hasOpenAI: Boolean(openaiApiKey), + hasDeepSeek: Boolean(deepseekApiKey), }); - return NextResponse.json( - { error: "Invalid response from AI provider" }, - { status: 500 }, - ); + + parsedResponse = buildFallbackPlan({ + activityLevel: profile.activityLevel, + fitnessGoals: profile.fitnessGoals, + medicalConditions: profile.medicalConditions, + }); + usedFallbackPlan = true; } const recommendation = await db.createRecommendation({ @@ -322,6 +442,7 @@ export async function POST() { timestamp: new Date().toISOString(), createdGoals: createdGoals.length, pausedGoals: linkedGoals.length, + usedFallbackPlan, }, }); } catch (error) { diff --git a/apps/mobile/src/api/recommendations.ts b/apps/mobile/src/api/recommendations.ts index b82f2d1..9bffc3c 100644 --- a/apps/mobile/src/api/recommendations.ts +++ b/apps/mobile/src/api/recommendations.ts @@ -18,6 +18,16 @@ export interface Recommendation { updatedAt: string; } +interface RecommendationMeta { + usedFallbackPlan?: boolean; +} + +interface RecommendationApiEnvelope { + success?: boolean; + data?: Recommendation; + meta?: RecommendationMeta; +} + export interface GenerateRecommendationRequest { userId: string; useExternalModel?: boolean; @@ -117,7 +127,7 @@ export async function generateSelfRecommendation( token: string | null, ): Promise { try { - const response = await apiClient.post( + const response = await apiClient.post( `${API_ENDPOINTS.RECOMMENDATIONS}/generate-self`, {}, withAuth(token), diff --git a/apps/mobile/src/app/(tabs)/goals.tsx b/apps/mobile/src/app/(tabs)/goals.tsx index af76f20..ea7e7ec 100644 --- a/apps/mobile/src/app/(tabs)/goals.tsx +++ b/apps/mobile/src/app/(tabs)/goals.tsx @@ -203,7 +203,7 @@ export default function GoalsScreen() { await Promise.all([refetchRecommendations(), refetchGoals()]); Alert.alert( "Plan Ready", - "Your new AI activity plan has been generated and active goals were added.", + "Your new activity plan is ready and active goals were added.", ); } catch (error) { const message =