const express = require('express'); const exercisesData = require('./data/exercises.json'); const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434'; const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'deepseek-v3.2:cloud'; const GEMINI_API_KEY = process.env.GOOGLE_API_KEY; const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; const OPENROUTER_BASE_URL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1'; const VALID_FITNESS_LEVELS = ['beginner', 'intermediate', 'advanced']; const VALID_GOALS = ['strength', 'hypertrophy', 'fat_loss', 'endurance', 'mobility', 'general_fitness']; const difficultyRank = { beginner: 1, intermediate: 2, advanced: 3 }; const normalizeGoals = (goals) => { if (!goals) return []; if (Array.isArray(goals)) { return goals.map((goal) => String(goal).trim()).filter(Boolean); } if (typeof goals === 'string') { return goals.split(',').map((goal) => goal.trim()).filter(Boolean); } return []; }; const normalizeList = (value) => { if (!value) return []; if (Array.isArray(value)) { return value.map((item) => String(item).trim()).filter(Boolean); } if (typeof value === 'string') { return value.split(',').map((item) => item.trim()).filter(Boolean); } return []; }; const validatePayload = (payload) => { const errors = []; const fitnessLevel = payload?.fitness_level; const goals = normalizeGoals(payload?.goals); const availableTime = Number(payload?.available_time); if (!fitnessLevel || typeof fitnessLevel !== 'string' || !VALID_FITNESS_LEVELS.includes(fitnessLevel)) { errors.push('fitness_level is required and must be beginner, intermediate, or advanced'); } if (!goals.length) { errors.push('goals is required and must be a non-empty array or comma-separated string'); } else { const invalidGoals = goals.filter((goal) => !VALID_GOALS.includes(goal)); if (invalidGoals.length) { errors.push(`goals contains invalid values: ${invalidGoals.join(', ')}`); } } if (!Number.isFinite(availableTime) || availableTime <= 0) { errors.push('available_time is required and must be a positive number (minutes)'); } return { errors, goals, availableTime }; }; const buildPrompt = ({ fitnessLevel, goals, availableTime, equipment, focusMuscles, limit, exercises }) => { const coachPersona = `Du är Coach, en erfaren styrke- och konditionscoach (15+ års erfarenhet).\n` + `- Direkt och tydlig, inga fluff.\n- Anpassar språk efter nivå.\n- Prioritera säkerhet.\n- Ge alltid alternativ.\n` + `Svara på svenska.`; const requestContext = { fitness_level: fitnessLevel, goals, available_time_minutes: availableTime, equipment, focus_muscles: focusMuscles, limit }; const exerciseCatalog = exercises.map((exercise) => ({ id: exercise.id, name: exercise.name, name_en: exercise.name_en, category: exercise.category, primary_muscles: exercise.primary_muscles, secondary_muscles: exercise.secondary_muscles, equipment: exercise.equipment, difficulty: exercise.difficulty, alternatives: exercise.alternatives })); return `${coachPersona}\n\n` + `Uppgift: Rekommendera övningar för användaren baserat på kontexten nedan.\n` + `- Välj endast från katalogen.\n- Anpassa set/reps/rest till mål och nivå.\n- Motivera kort varför varje övning passar.\n- Svara med exakt JSON enligt schema.\n\n` + `KONTEKST:\n${JSON.stringify(requestContext)}\n\n` + `KATALOG:\n${JSON.stringify(exerciseCatalog)}\n\n` + `SCHEMA:\n` + `{"recommendations":[{"id":"","sets":0,"reps":"","rest_seconds":0,"reason":"","alternatives":[]}],"notes":""}`; }; const extractJsonPayload = (text) => { if (!text || typeof text !== 'string') { throw new Error('No response text to parse'); } const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start === -1 || end === -1 || end <= start) { throw new Error('No JSON object found in response'); } const jsonString = text.slice(start, end + 1); return JSON.parse(jsonString); }; const parseRecommendations = (payload, exerciseMap) => { if (!payload || !Array.isArray(payload.recommendations)) { throw new Error('Invalid recommendations payload'); } const recommendations = payload.recommendations .map((rec) => { const exercise = exerciseMap.get(rec.id); if (!exercise) return null; return { id: exercise.id, name: exercise.name, name_en: exercise.name_en, sets: Number(rec.sets) || 3, reps: rec.reps || '8-12', rest_seconds: Number(rec.rest_seconds) || 90, reason: rec.reason || 'Bra match för ditt mål och din nivå.', alternatives: Array.isArray(rec.alternatives) && rec.alternatives.length ? rec.alternatives : exercise.alternatives || [] }; }) .filter(Boolean); if (!recommendations.length) { throw new Error('No valid recommendations after parsing'); } return { recommendations, notes: payload.notes || '' }; }; const buildHeuristicRecommendations = ({ fitnessLevel, goals, availableTime, equipment, focusMuscles, limit }) => { const maxDifficulty = difficultyRank[fitnessLevel] || 2; const equipmentSet = new Set((equipment || []).map((item) => item.toLowerCase())); const focusSet = new Set((focusMuscles || []).map((item) => item.toLowerCase())); const goalWeights = { strength: { compound: 3, isolation: 1 }, hypertrophy: { compound: 2, isolation: 2 }, fat_loss: { compound: 2, isolation: 1 }, endurance: { compound: 1, isolation: 2 }, mobility: { compound: 1, isolation: 2 }, general_fitness: { compound: 2, isolation: 1 } }; const filteredExercises = exercisesData.exercises.filter((exercise) => { const diffOk = (difficultyRank[exercise.difficulty] || 2) <= maxDifficulty; if (!diffOk) return false; if (equipmentSet.size === 0) return true; if (!exercise.equipment || exercise.equipment.length === 0) return true; return exercise.equipment.some((item) => equipmentSet.has(item.toLowerCase())); }); const exercises = filteredExercises.length ? filteredExercises : exercisesData.exercises; const scored = exercises.map((exercise) => { let score = 0; goals.forEach((goal) => { const weights = goalWeights[goal] || goalWeights.general_fitness; score += weights[exercise.category] || 0; }); if (focusSet.size) { if (exercise.primary_muscles?.some((muscle) => focusSet.has(muscle.toLowerCase()))) { score += 3; } else if (exercise.secondary_muscles?.some((muscle) => focusSet.has(muscle.toLowerCase()))) { score += 1; } } if (!exercise.equipment || exercise.equipment.length === 0) { score += 1; } return { exercise, score }; }); scored.sort((a, b) => b.score - a.score); const timeBasedLimit = availableTime <= 20 ? 3 : availableTime <= 35 ? 4 : availableTime <= 50 ? 6 : 8; const finalLimit = Math.min(limit || timeBasedLimit, 10); const selected = scored.slice(0, finalLimit); return selected.map(({ exercise }) => ({ id: exercise.id, name: exercise.name, name_en: exercise.name_en, sets: exercise.category === 'compound' ? 4 : 3, reps: goals.includes('strength') ? '4-6' : '8-12', rest_seconds: exercise.category === 'compound' ? 120 : 60, reason: `Passar ${goals.join(', ')} med fokus på ${exercise.primary_muscles.join(', ')}.`, alternatives: exercise.alternatives || [] })); }; const extractProviderText = (provider, data) => { if (provider === 'ollama') { return data?.response || ''; } if (provider === 'gemini') { return data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; } if (provider === 'openrouter') { return data?.choices?.[0]?.message?.content || ''; } return ''; }; const generateRecommendationsWithFallback = async ({ prompt }) => { if (typeof fetch !== 'function') { throw new Error('Fetch API not available in this runtime'); } // Tier 1: Ollama try { console.log(`📍 [Recommend] Tier 1: Ollama (${OLLAMA_MODEL})`); const response = await fetch(`${OLLAMA_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: OLLAMA_MODEL, prompt, stream: false, temperature: 0.6 }), timeout: 30000 }); if (response.ok) { const data = await response.json(); console.log('✅ [Recommend] Ollama success'); return { provider: 'ollama', data }; } console.warn(`⚠️ [Recommend] Ollama error: ${response.status}`); } catch (err) { console.warn(`⚠️ [Recommend] Ollama failed: ${err.message}`); } // Tier 2: Gemini if (GEMINI_API_KEY) { try { console.log('📍 [Recommend] Tier 2: Gemini'); const response = await fetch( `https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent?key=${GEMINI_API_KEY}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: 0.6 } }) } ); if (response.ok) { const data = await response.json(); console.log('✅ [Recommend] Gemini success'); return { provider: 'gemini', data }; } if (response.status === 429 || response.status === 403) { console.warn('⚠️ [Recommend] Gemini quota exceeded'); } else { console.warn(`⚠️ [Recommend] Gemini error: ${response.status}`); } } catch (err) { console.warn(`⚠️ [Recommend] Gemini failed: ${err.message}`); } } // Tier 3: OpenRouter if (OPENROUTER_API_KEY) { try { console.log('📍 [Recommend] Tier 3: OpenRouter'); const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://gravl.app' }, body: JSON.stringify({ model: 'openai/gpt-4', messages: [{ role: 'user', content: prompt }], temperature: 0.6, max_tokens: 1200 }) }); if (response.ok) { const data = await response.json(); console.log('✅ [Recommend] OpenRouter success'); return { provider: 'openrouter', data }; } console.warn(`⚠️ [Recommend] OpenRouter error: ${response.status}`); } catch (err) { console.warn(`⚠️ [Recommend] OpenRouter failed: ${err.message}`); } } throw new Error('All recommendation providers failed (Ollama → Gemini → OpenRouter)'); }; const createExerciseRecommendationRouter = () => { const router = express.Router(); const exerciseMap = new Map(exercisesData.exercises.map((exercise) => [exercise.id, exercise])); /** * POST /api/exercises/recommend * Request body: * { * "fitness_level": "beginner" | "intermediate" | "advanced", * "goals": ["strength" | "hypertrophy" | "fat_loss" | "endurance" | "mobility" | "general_fitness"], * "available_time": 30, * "equipment": ["barbell", "dumbbells"], * "focus_muscles": ["chest", "back"], * "limit": 6 * } */ router.post('/recommend', async (req, res) => { const { errors, goals, availableTime } = validatePayload(req.body); if (errors.length) { return res.status(400).json({ error: 'Validation failed', details: errors }); } const fitnessLevel = req.body.fitness_level; const equipment = normalizeList(req.body.equipment); const focusMuscles = normalizeList(req.body.focus_muscles); const limit = Number.isFinite(Number(req.body.limit)) ? Math.min(Number(req.body.limit), 10) : null; const prompt = buildPrompt({ fitnessLevel, goals, availableTime, equipment, focusMuscles, limit, exercises: exercisesData.exercises }); try { const { provider, data } = await generateRecommendationsWithFallback({ prompt }); const text = extractProviderText(provider, data); const parsedPayload = extractJsonPayload(text); const aiRecommendations = parseRecommendations(parsedPayload, exerciseMap); return res.json({ recommendations: aiRecommendations.recommendations, notes: aiRecommendations.notes, provider, status: 'success' }); } catch (err) { console.warn(`⚠️ [Recommend] Falling back to heuristic recommendations: ${err.message}`); const fallbackRecommendations = buildHeuristicRecommendations({ fitnessLevel, goals, availableTime, equipment, focusMuscles, limit }); return res.json({ recommendations: fallbackRecommendations, notes: 'Fallback recommendations generated without AI provider.', provider: 'fallback', status: 'degraded' }); } }); return router; }; module.exports = { createExerciseRecommendationRouter };