408 lines
13 KiB
JavaScript
408 lines
13 KiB
JavaScript
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
|
|
};
|