add resilient self-plan generation with provider fallback
This commit is contained in:
parent
73218402f6
commit
ef9f39e564
@ -22,6 +22,45 @@ interface ParsedPlanItem {
|
|||||||
| "custom";
|
| "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"] {
|
function inferGoalType(text: string): ParsedPlanItem["goalType"] {
|
||||||
const normalized = text.toLowerCase();
|
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<GeneratedPlanContent> {
|
||||||
|
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<GeneratedPlanContent> {
|
||||||
|
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<GeneratedPlanContent> {
|
||||||
|
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() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
@ -180,71 +339,32 @@ export async function POST() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||||
if (!openaiApiKey) {
|
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "AI provider unavailable. Try again later." },
|
|
||||||
{ status: 503 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const openaiResponse = await fetch(
|
let parsedResponse: GeneratedPlanContent;
|
||||||
"https://api.openai.com/v1/chat/completions",
|
let usedFallbackPlan = false;
|
||||||
{
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
parsedResponse = JSON.parse(openaiData.choices[0].message.content);
|
if (openaiApiKey) {
|
||||||
} catch (e) {
|
parsedResponse = await generateWithOpenAI(openaiApiKey, prompt);
|
||||||
log.error("Failed to parse self-generate OpenAI response", e, {
|
} else if (deepseekApiKey) {
|
||||||
|
parsedResponse = await generateWithDeepSeek(deepseekApiKey, prompt);
|
||||||
|
} else {
|
||||||
|
parsedResponse = await generateWithOllama(prompt);
|
||||||
|
}
|
||||||
|
} catch (providerError) {
|
||||||
|
log.error("Self-generate provider failed", providerError, {
|
||||||
userId,
|
userId,
|
||||||
|
hasOpenAI: Boolean(openaiApiKey),
|
||||||
|
hasDeepSeek: Boolean(deepseekApiKey),
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid response from AI provider" },
|
parsedResponse = buildFallbackPlan({
|
||||||
{ status: 500 },
|
activityLevel: profile.activityLevel,
|
||||||
);
|
fitnessGoals: profile.fitnessGoals,
|
||||||
|
medicalConditions: profile.medicalConditions,
|
||||||
|
});
|
||||||
|
usedFallbackPlan = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recommendation = await db.createRecommendation({
|
const recommendation = await db.createRecommendation({
|
||||||
@ -322,6 +442,7 @@ export async function POST() {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
createdGoals: createdGoals.length,
|
createdGoals: createdGoals.length,
|
||||||
pausedGoals: linkedGoals.length,
|
pausedGoals: linkedGoals.length,
|
||||||
|
usedFallbackPlan,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -18,6 +18,16 @@ export interface Recommendation {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecommendationMeta {
|
||||||
|
usedFallbackPlan?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecommendationApiEnvelope {
|
||||||
|
success?: boolean;
|
||||||
|
data?: Recommendation;
|
||||||
|
meta?: RecommendationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenerateRecommendationRequest {
|
export interface GenerateRecommendationRequest {
|
||||||
userId: string;
|
userId: string;
|
||||||
useExternalModel?: boolean;
|
useExternalModel?: boolean;
|
||||||
@ -117,7 +127,7 @@ export async function generateSelfRecommendation(
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Recommendation> {
|
): Promise<Recommendation> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post<RecommendationApiEnvelope>(
|
||||||
`${API_ENDPOINTS.RECOMMENDATIONS}/generate-self`,
|
`${API_ENDPOINTS.RECOMMENDATIONS}/generate-self`,
|
||||||
{},
|
{},
|
||||||
withAuth(token),
|
withAuth(token),
|
||||||
|
|||||||
@ -203,7 +203,7 @@ export default function GoalsScreen() {
|
|||||||
await Promise.all([refetchRecommendations(), refetchGoals()]);
|
await Promise.all([refetchRecommendations(), refetchGoals()]);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Plan Ready",
|
"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) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user