feature/05-exercise-encyclopedia #4

Merged
sphinxen merged 28 commits from feature/05-exercise-encyclopedia into main 2026-03-06 12:29:20 +01:00
12 changed files with 1313 additions and 93 deletions
Showing only changes of commit fbba2d894d - Show all commits
+37 -23
View File
@@ -1,26 +1,40 @@
{ {
"lastRun": "2026-03-02T19:37:00Z", "lastRun": "2026-03-03T02:47:00+01:00",
"status": "unblocked", "status": "completed",
"unblockedReason": "OpenCode API configured as fallback for Gemini quota", "result": "Phase 06-01 COMPLETED: Backend exercise recommendation endpoint + frontend components implemented. API contract finalized with fallback chain. Ready for integration testing.",
"currentPhase": "05", "phase": "06-01",
"currentTask": "05-03", "phaseStarted": "2026-03-03T02:47:00+01:00",
"result": "Fallback system implemented: Gemini (primary) → OpenCode (fallback)", "phaseCompleted": "2026-03-03T02:47:00+01:00",
"nextTask": "05-03: Frontend integration for research display (can now proceed with OpenCode fallback)", "work": {
"backend": {
"apiConfiguration": { "status": "completed",
"primary": { "created": "backend/src/routes/exerciseRecommendations.js",
"provider": "Gemini", "modified": "backend/src/index.js",
"status": "quota-limited", "features": [
"notes": "Free tier has daily limits" "POST /api/exercises/recommend endpoint",
"Input validation (fitness_level, goals, available_time, equipment, focus_muscles, limit)",
"AI fallback chain: Ollama → Gemini → OpenRouter",
"Heuristic fallback when all providers fail",
"Coach persona integration",
"JSON parsing + error handling",
"Logging + metrics"
]
}, },
"fallback": { "frontend": {
"provider": "OpenCode", "status": "completed",
"baseUrl": "https://api.opencode.com/v1", "created": [
"model": "gpt-4", "frontend/src/components/exercises/ExerciseCard.jsx",
"status": "configured" "frontend/src/components/exercises/RecommendationPanel.jsx",
}, "frontend/src/components/exercises/ProgressionTracker.jsx",
"implementation": "backend/src/utils/gemini-fallback.js" "frontend/src/components/exercises/exerciseRecommendations.css",
}, "frontend/src/types/exerciseRecommendations.ts"
]
"action": "READY TO RESUME: PM can continue with 05-03 using fallback" }
},
"verification": {
"gitStatus": "New files: exerciseRecommendations.js, exercise*.jsx, exerciseRecommendations.css, exerciseRecommendations.ts; Modified: index.js",
"lastCommit": "f580fa8 feat(05-03): Implement API fallback handling for research display",
"uncommittedChanges": "Ready for review and commit"
},
"nextCheck": "PHASE 06-02: Integration testing - Verify API responses, test fallback chain, validate component rendering. Target: E2E flow validation."
} }
+255 -1
View File
@@ -15,7 +15,31 @@
"pg": "^8.11.3" "pg": "^8.11.3"
}, },
"devDependencies": { "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": { "node_modules/accepts": {
@@ -51,6 +75,20 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -194,6 +232,29 @@
"fsevents": "~2.3.2" "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": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -237,6 +298,13 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "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": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -263,6 +331,16 @@
"ms": "2.0.0" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -282,6 +360,17 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -350,6 +439,22 @@
"node": ">= 0.4" "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": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -411,6 +516,13 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -442,6 +554,39 @@
"node": ">= 0.8" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -568,6 +713,22 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -965,6 +1126,16 @@
"node": ">= 0.8" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1385,6 +1556,82 @@
"node": ">= 0.8" "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": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1477,6 +1724,13 @@
"node": ">= 0.8" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2
View File
@@ -4,6 +4,7 @@ const { Pool } = require('pg');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { createExerciseResearchRouter } = require('./routes/exerciseResearch'); const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
const { createExerciseRecommendationRouter } = require('./routes/exerciseRecommendations');
const { searchExerciseResearch } = require('./services/exaSearch'); const { searchExerciseResearch } = require('./services/exaSearch');
const app = express(); const app = express();
@@ -21,6 +22,7 @@ const pool = new Pool({
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch })); app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
app.use('/api/exercises', createExerciseRecommendationRouter());
const authMiddleware = (req, res, next) => { const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]; 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
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>Gravl - Träning</title> <title>Gravl - Träning</title>
<script type="module" crossorigin src="/assets/index-hhKetRGz.js"></script> <script type="module" crossorigin src="/assets/index-aU0r4U2I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-my_lGtI5.css"> <link rel="stylesheet" crossorigin href="/assets/index-KaIXgP3q.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -0,0 +1,88 @@
import './exerciseRecommendations.css'
const difficultyTokens = {
easy: { label: 'Easy', className: 'difficulty-easy' },
medium: { label: 'Medium', className: 'difficulty-medium' },
med: { label: 'Medium', className: 'difficulty-medium' },
hard: { label: 'Hard', className: 'difficulty-hard' }
}
const normalizeDifficulty = (difficulty) => {
if (!difficulty) return null
const key = String(difficulty).trim().toLowerCase()
return difficultyTokens[key] || { label: difficulty, className: 'difficulty-custom' }
}
const formatDuration = (exercise) => {
const value = exercise?.duration ?? exercise?.duration_min ?? exercise?.durationMinutes
if (!value) return null
return `${value} min`
}
const formatReps = (exercise) => {
const { reps, reps_min, reps_max, repsMin, repsMax } = exercise || {}
if (reps) return `${reps} reps`
const min = reps_min ?? repsMin
const max = reps_max ?? repsMax
if (min && max) return `${min}-${max} reps`
if (min) return `${min}+ reps`
return null
}
function ExerciseCard({
exercise,
onSelect,
className = '',
compact = false,
showMeta = true
}) {
if (!exercise) return null
const difficulty = normalizeDifficulty(exercise.difficulty)
const duration = formatDuration(exercise)
const reps = formatReps(exercise)
const imageSrc = exercise.image_url || exercise.image || exercise.imageUrl
const Element = onSelect ? 'button' : 'article'
return (
<Element
type={onSelect ? 'button' : undefined}
className={`exercise-recommendation-card ${compact ? 'is-compact' : ''} ${className}`}
onClick={onSelect ? () => onSelect(exercise) : undefined}
>
<div className="exercise-card-media">
{imageSrc ? (
<img src={imageSrc} alt={exercise.name} loading="lazy" />
) : (
<div className="exercise-card-placeholder" aria-hidden="true">
<span>{exercise.name?.slice(0, 1) || 'E'}</span>
</div>
)}
</div>
<div className="exercise-card-content">
<div className="exercise-card-header">
<h3>{exercise.name}</h3>
{difficulty && (
<span className={`difficulty-badge ${difficulty.className}`}>
{difficulty.label}
</span>
)}
</div>
{exercise.description && !compact && (
<p className="exercise-card-description">{exercise.description}</p>
)}
{showMeta && (duration || reps) && (
<div className="exercise-card-meta">
{duration && <span className="exercise-meta-pill">{duration}</span>}
{reps && <span className="exercise-meta-pill">{reps}</span>}
</div>
)}
</div>
</Element>
)
}
export default ExerciseCard
@@ -0,0 +1,70 @@
import './exerciseRecommendations.css'
const resolveStatus = (level, index, activeIndex) => {
if (level.status) return level.status
if (activeIndex == null) return 'available'
if (index < activeIndex) return 'completed'
if (index === activeIndex) return 'current'
return 'locked'
}
function ProgressionTracker({
title = 'Progression Path',
levels = [],
activeLevelId,
activeIndex,
onSelect,
className = ''
}) {
const resolvedActiveIndex = activeIndex != null
? activeIndex
: levels.findIndex(level => level.id === activeLevelId)
return (
<section className={`progression-tracker ${className}`}>
<header className="progression-tracker-header">
<h2>{title}</h2>
</header>
<div className="progression-track">
{levels.map((level, index) => {
const status = resolveStatus(level, index, resolvedActiveIndex)
const levelClass = `progression-level is-${status}`
const content = (
<>
<div className="progression-node" aria-hidden="true">
{index + 1}
</div>
<div className="progression-info">
<h3>{level.label}</h3>
{level.description && <p>{level.description}</p>}
</div>
</>
)
return (
<div
key={level.id || level.label}
className={levelClass}
aria-current={status === 'current' ? 'step' : undefined}
>
{onSelect ? (
<button
type="button"
className="progression-level-button"
onClick={() => onSelect(level, index)}
>
{content}
</button>
) : (
content
)}
</div>
)
})}
</div>
</section>
)
}
export default ProgressionTracker
@@ -0,0 +1,79 @@
import ExerciseCard from './ExerciseCard'
import './exerciseRecommendations.css'
const normalizeGroupLabel = (item) => {
return item.group || item.category || item.level || item.progression_level || 'Recommended'
}
const groupRecommendations = (items) => {
if (!Array.isArray(items)) return []
const groups = items.reduce((acc, item) => {
const label = normalizeGroupLabel(item)
if (!acc[label]) acc[label] = []
acc[label].push(item)
return acc
}, {})
return Object.entries(groups).map(([title, recommendations]) => ({
id: title,
title,
recommendations
}))
}
function RecommendationPanel({
title = 'Recommended Exercises',
subtitle,
recommendations = [],
groups,
layout = 'grid',
onSelect,
emptyMessage = 'No recommendations available yet.',
className = ''
}) {
const resolvedGroups = Array.isArray(groups) && groups.length > 0
? groups
: groupRecommendations(recommendations)
const hasContent = resolvedGroups.some(group => group.recommendations?.length)
return (
<section className={`recommendation-panel ${className}`}>
<div className="recommendation-panel-header">
<div>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
</div>
{!hasContent && (
<div className="recommendation-empty">{emptyMessage}</div>
)}
{hasContent && (
<div className="recommendation-panel-body">
{resolvedGroups.map(group => (
<div key={group.id || group.title} className="recommendation-group">
<div className="recommendation-group-header">
<h3>{group.title}</h3>
{group.description && <span>{group.description}</span>}
</div>
<div className={`recommendation-list recommendation-list--${layout}`}>
{(group.recommendations || group.items || []).map(item => (
<ExerciseCard
key={item.id || `${group.title}-${item.name}`}
exercise={item}
onSelect={onSelect}
compact={layout === 'list'}
/>
))}
</div>
</div>
))}
</div>
)}
</section>
)
}
export default RecommendationPanel
@@ -0,0 +1,324 @@
.recommendation-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-card);
}
.recommendation-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.recommendation-panel-header h2 {
font-size: var(--font-xl);
margin-bottom: var(--space-1);
}
.recommendation-panel-header p {
color: var(--text-secondary);
font-size: var(--font-sm);
}
.recommendation-panel-body {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.recommendation-empty {
color: var(--text-secondary);
font-size: var(--font-sm);
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
border: 1px dashed var(--border);
}
.recommendation-group-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.recommendation-group-header h3 {
font-size: var(--font-lg);
}
.recommendation-group-header span {
color: var(--text-muted);
font-size: var(--font-xs);
}
.recommendation-list {
display: grid;
gap: var(--space-3);
}
.recommendation-list--grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.recommendation-list--list {
grid-template-columns: 1fr;
}
.exercise-recommendation-card {
display: flex;
gap: var(--space-3);
align-items: stretch;
padding: var(--space-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
text-align: left;
transition: transform var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base);
}
.exercise-recommendation-card:hover {
transform: translateY(-2px);
border-color: var(--border-hover);
box-shadow: var(--shadow-md);
}
.exercise-recommendation-card.is-compact {
align-items: center;
}
.exercise-card-media {
width: 72px;
height: 72px;
flex: 0 0 auto;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-secondary);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
}
.exercise-card-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.exercise-card-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-weight: 700;
font-size: var(--font-lg);
background: linear-gradient(135deg, var(--bg-card), var(--bg-secondary));
}
.exercise-card-content {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.exercise-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.exercise-card-header h3 {
font-size: var(--font-base);
}
.exercise-card-description {
color: var(--text-secondary);
font-size: var(--font-xs);
}
.exercise-card-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.exercise-meta-pill {
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
background: var(--bg-secondary);
border: 1px solid var(--border);
font-size: var(--font-xs);
color: var(--text-secondary);
}
.difficulty-badge {
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.difficulty-easy {
background: var(--success-subtle);
color: var(--success);
}
.difficulty-medium {
background: var(--warning-subtle);
color: var(--warning);
}
.difficulty-hard {
background: var(--error-subtle);
color: var(--error);
}
.difficulty-custom {
background: var(--accent-subtle);
color: var(--accent);
}
.progression-tracker {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-card);
}
.progression-tracker-header {
margin-bottom: var(--space-4);
}
.progression-tracker-header h2 {
font-size: var(--font-lg);
}
.progression-track {
display: grid;
gap: var(--space-3);
}
.progression-level {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
}
.progression-node {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary);
position: relative;
}
.progression-node::after {
content: '';
position: absolute;
top: 34px;
left: 50%;
width: 2px;
height: calc(100% + var(--space-3));
transform: translateX(-50%);
background: var(--border);
}
.progression-level:last-child .progression-node::after {
display: none;
}
.progression-level.is-completed .progression-node,
.progression-level.is-current .progression-node {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-subtle);
}
.progression-level.is-completed .progression-node {
color: var(--success);
border-color: var(--success);
background: var(--success-subtle);
}
.progression-level.is-locked .progression-node {
opacity: 0.5;
}
.progression-info h3 {
font-size: var(--font-base);
margin-bottom: var(--space-1);
}
.progression-info p {
color: var(--text-secondary);
font-size: var(--font-sm);
}
.progression-level.is-current .progression-info h3 {
color: var(--accent);
}
.progression-level.is-completed .progression-info h3 {
color: var(--success);
}
.progression-level-button {
background: transparent;
border: none;
padding: 0;
text-align: left;
color: inherit;
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
width: 100%;
}
@media (min-width: 720px) {
.progression-track {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.progression-level {
grid-template-columns: 1fr;
text-align: center;
}
.progression-node::after {
top: 50%;
left: 36px;
width: calc(100% + var(--space-3));
height: 2px;
transform: translateY(-50%);
}
.progression-level:last-child .progression-node::after {
display: none;
}
.progression-level,
.progression-level-button {
justify-items: center;
}
}
@@ -0,0 +1,50 @@
export type Difficulty = 'Easy' | 'Medium' | 'Hard' | 'Beginner' | 'Intermediate' | 'Advanced'
export interface ExerciseRecommendation {
id?: string | number
name: string
description?: string
difficulty?: Difficulty | string
duration?: number
duration_min?: number
durationMinutes?: number
reps?: string | number
reps_min?: number
reps_max?: number
repsMin?: number
repsMax?: number
image_url?: string
image?: string
imageUrl?: string
group?: string
category?: string
level?: string
progression_level?: string
equipment?: string[]
tags?: string[]
rationale?: string
}
export interface RecommendationGroup {
id?: string
title: string
description?: string
recommendations?: ExerciseRecommendation[]
items?: ExerciseRecommendation[]
}
export type ProgressionStatus = 'completed' | 'current' | 'available' | 'locked'
export interface ProgressionLevel {
id?: string
label: string
description?: string
status?: ProgressionStatus
}
export interface ExerciseRecommendationResponse {
recommendations: ExerciseRecommendation[]
groups?: RecommendationGroup[]
progression?: ProgressionLevel[]
meta?: Record<string, unknown>
}