feat(06-01): Exercise recommendations API endpoint + frontend components (coach-assisted suggestions)
This commit is contained in:
Generated
+255
-1
@@ -15,7 +15,31 @@
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -51,6 +75,20 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -194,6 +232,29 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -237,6 +298,13 @@
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookiejar": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
@@ -263,6 +331,16 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -282,6 +360,17 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -350,6 +439,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -411,6 +516,13 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -442,6 +554,39 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz",
|
||||
"integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0",
|
||||
"qs": "^6.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -568,6 +713,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -965,6 +1126,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1385,6 +1556,82 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz",
|
||||
"integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==",
|
||||
"deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.0",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.4",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.0",
|
||||
"formidable": "^2.1.2",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.11.0",
|
||||
"semver": "^7.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.4.0 <13 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "6.3.4",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz",
|
||||
"integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==",
|
||||
"deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^8.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
@@ -1477,6 +1724,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -4,6 +4,7 @@ const { Pool } = require('pg');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
|
||||
const { createExerciseRecommendationRouter } = require('./routes/exerciseRecommendations');
|
||||
const { searchExerciseResearch } = require('./services/exaSearch');
|
||||
|
||||
const app = express();
|
||||
@@ -21,6 +22,7 @@ const pool = new Pool({
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
|
||||
app.use('/api/exercises', createExerciseRecommendationRouter());
|
||||
|
||||
const authMiddleware = (req, res, next) => {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
const express = require('express');
|
||||
|
||||
const exercisesData = require('../../../agents/coach/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
|
||||
};
|
||||
Reference in New Issue
Block a user