AI Plan generation enhanced

This commit is contained in:
AG
2025-12-16 11:24:09 +02:00
parent 8d4eed77ea
commit cb0bd1a55d
4 changed files with 305 additions and 70 deletions

View File

@@ -181,7 +181,7 @@ const SortablePlanStep: React.FC<SortablePlanStepProps> = ({ step, index, toggle
const Plans: React.FC<PlansProps> = ({ lang }) => {
const { currentUser } = useAuth();
const userId = currentUser?.id || '';
const { plans, savePlan, deletePlan, refreshData } = useSession();
const { plans, sessions, savePlan, deletePlan, refreshData } = useSession();
const { startSession } = useActiveWorkout();
const [isEditing, setIsEditing] = useState(false);
@@ -226,7 +226,9 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
const [aiLoading, setAILoading] = useState(false);
const [aiError, setAIError] = useState<string | null>(null);
const [aiDuration, setAIDuration] = useState(60); // Default 1 hour in minutes
const [aiEquipment, setAIEquipment] = useState<'none' | 'essentials' | 'free_weights' | 'full_gym'>('full_gym');
const [aiEquipment, setAIEquipment] = useState<'none' | 'essentials' | 'free_weights' | 'full_gym'>('none');
const [aiLevel, setAILevel] = useState<'beginner' | 'intermediate' | 'advanced'>('intermediate');
const [aiIntensity, setAIIntensity] = useState<'low' | 'moderate' | 'high'>('moderate');
const [generatedPlanPreview, setGeneratedPlanPreview] = useState<{
name: string;
description: string;
@@ -417,7 +419,17 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
? aiPrompt
: (lang === 'ru' ? 'Создай план тренировки' : 'Create a workout plan');
const aiPlan = await generateWorkoutPlan(prompt, availableNames, lang, aiDuration, aiEquipment);
const aiPlan = await generateWorkoutPlan(
prompt,
availableNames,
lang,
aiDuration,
aiEquipment,
aiLevel,
aiIntensity,
sessions,
currentUser?.profile
);
setGeneratedPlanPreview(aiPlan);
} catch (err: any) {
console.error('AI plan generation error:', err);
@@ -488,7 +500,9 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
// Reset state and close
setAIPrompt('');
setAIDuration(60);
setAIEquipment('full_gym');
setAIEquipment('none');
setAILevel('intermediate');
setAIIntensity('moderate');
setGeneratedPlanPreview(null);
setShowAISheet(false);
setFabMenuOpen(false);
@@ -813,6 +827,46 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
</div>
</div>
{/* Level Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface">{t('level', lang)}</label>
<div className="flex gap-2">
{(['beginner', 'intermediate', 'advanced'] as const).map((lvl) => (
<button
key={lvl}
onClick={() => setAILevel(lvl)}
disabled={aiLoading}
className={`flex-1 p-2 rounded-xl text-sm font-medium transition-all ${aiLevel === lvl
? 'bg-primary text-on-primary'
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
}`}
>
{t(`level_${lvl}` as any, lang)}
</button>
))}
</div>
</div>
{/* Intensity Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface">{t('intensity', lang)}</label>
<div className="flex gap-2">
{(['low', 'moderate', 'high'] as const).map((int) => (
<button
key={int}
onClick={() => setAIIntensity(int)}
disabled={aiLoading}
className={`flex-1 p-2 rounded-xl text-sm font-medium transition-all ${aiIntensity === int
? 'bg-primary text-on-primary'
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
}`}
>
{t(`intensity_${int}` as any, lang)}
</button>
))}
</div>
</div>
{/* Additional Requirements */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface">

View File

@@ -26,61 +26,22 @@ export const createFitnessChat = (
// Generate a unique session ID for this chat instance
const sessionId = generateId();
// Summarize workout history
const workoutSummary = history.slice(0, 20).map(s => ({
date: new Date(s.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US'),
userWeight: s.userBodyWeight,
planName: s.planName,
exercises: s.sets.map(set => {
const parts = [];
parts.push(set.exerciseName);
if (set.weight) parts.push(`${set.weight}${lang === 'ru' ? 'кг' : 'kg'}`);
if (set.reps) parts.push(`${set.reps}${lang === 'ru' ? 'повт' : 'reps'}`);
if (set.distanceMeters) parts.push(`${set.distanceMeters}${lang === 'ru' ? 'м' : 'm'}`);
if (set.durationSeconds) parts.push(`${set.durationSeconds}${lang === 'ru' ? 'сек' : 's'}`);
return parts.join(' ');
}).join(', ')
}));
// Calculate personal records
const exerciseRecords = new Map<string, { maxWeight?: number; maxReps?: number; maxDistance?: number }>();
history.forEach(session => {
session.sets.forEach(set => {
const current = exerciseRecords.get(set.exerciseName) || {};
exerciseRecords.set(set.exerciseName, {
maxWeight: Math.max(current.maxWeight || 0, set.weight || 0),
maxReps: Math.max(current.maxReps || 0, set.reps || 0),
maxDistance: Math.max(current.maxDistance || 0, set.distanceMeters || 0)
});
});
});
// Reuse the logic via shared helper if possible, or keep as is if we want to include 'plans'.
// The helper getUserContextString currently doesn't include 'plans', so we will use it for history/profile and append plans manually.
const personalRecords = Array.from(exerciseRecords.entries()).map(([name, records]) => ({
exercise: name,
...records
}));
const userContext = getUserContextString(history, userProfile, lang);
// Build comprehensive system instruction
const systemInstruction = lang === 'ru' ? `
Ты — опытный и поддерживающий фитнес-тренер AI Coach.
Твоя задача — анализировать тренировки пользователя и давать краткие, полезные советы на русском языке.
ДАННЫЕ ПОЛЬЗОВАТЕЛЯ:
${userProfile ? `
- Вес: ${userProfile.weight || 'не указан'} кг
- Рост: ${userProfile.height || 'не указан'} см
- Пол: ${userProfile.gender || 'не указан'}
` : 'Профиль не заполнен'}
${userContext}
ТРЕНИРОВОЧНЫЕ ПЛАНЫ:
${plans && plans.length > 0 ? JSON.stringify(plans.map(p => ({ name: p.name, exercises: p.steps.map(s => s.exerciseName) }))) : 'Нет активных планов'}
ИСТОРИЯ ТРЕНИРОВОК (последние 20):
${JSON.stringify(workoutSummary, null, 2)}
ЛИЧНЫЕ РЕКОРДЫ:
${JSON.stringify(personalRecords, null, 2)}
ИНСТРУКЦИИ:
- Используй эти данные для анализа прогресса и ответов на вопросы
- Учитывай вес пользователя при анализе упражнений с собственным весом
@@ -91,22 +52,11 @@ export const createFitnessChat = (
You are an experienced and supportive fitness coach called AI Coach.
Your task is to analyze the user's workouts and provide concise, helpful advice in English.
USER PROFILE:
${userProfile ? `
- Weight: ${userProfile.weight || 'not specified'} kg
- Height: ${userProfile.height || 'not specified'} cm
- Gender: ${userProfile.gender || 'not specified'}
` : 'Profile not filled'}
${userContext}
WORKOUT PLANS:
${plans && plans.length > 0 ? JSON.stringify(plans.map(p => ({ name: p.name, exercises: p.steps.map(s => s.exerciseName) }))) : 'No active plans'}
WORKOUT HISTORY (last 20 sessions):
${JSON.stringify(workoutSummary, null, 2)}
PERSONAL RECORDS:
${JSON.stringify(personalRecords, null, 2)}
INSTRUCTIONS:
- Use this data to analyze progress and answer questions
- Consider user's weight when analyzing bodyweight exercises
@@ -153,32 +103,122 @@ interface AIPlanResponse {
// ... (other code)
// Helper to generate user context string
const getUserContextString = (history: WorkoutSession[], userProfile: UserProfile | undefined, lang: 'en' | 'ru') => {
// Summarize workout history (Last 8 sessions)
const workoutSummary = history.slice(0, 8).map(s => ({
date: new Date(s.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US'),
planName: s.planName || (lang === 'ru' ? 'Свободная тренировка' : 'Freestyle'),
exercises: s.sets.map(set => {
const parts = [];
parts.push(set.exerciseName);
if (set.weight) parts.push(`${set.weight}${lang === 'ru' ? 'кг' : 'kg'}`);
if (set.reps) parts.push(`${set.reps}${lang === 'ru' ? 'повт' : 'reps'}`);
return parts.join(' ');
}).join(', ')
}));
// Calculate personal records
const exerciseRecords = new Map<string, { maxWeight?: number; maxReps?: number }>();
history.forEach(session => {
session.sets.forEach(set => {
const current = exerciseRecords.get(set.exerciseName) || {};
exerciseRecords.set(set.exerciseName, {
maxWeight: Math.max(current.maxWeight || 0, set.weight || 0),
maxReps: Math.max(current.maxReps || 0, set.reps || 0),
});
});
});
const personalRecords = Array.from(exerciseRecords.entries())
.filter(([_, r]) => (r.maxWeight && r.maxWeight > 0) || (r.maxReps && r.maxReps > 10)) // Filter significant records
.map(([name, records]) => ({
exercise: name,
...records
}));
return lang === 'ru' ? `
ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ:
${userProfile ? `
- Вес: ${userProfile.weight || 'не указан'} кг
- Рост: ${userProfile.height || 'не указан'} см
- Пол: ${userProfile.gender || 'не указан'}
` : 'Профиль не заполнен'}
ИСТОРИЯ (последние 8 тренировок):
${JSON.stringify(workoutSummary, null, 2)}
ЛИЧНЫЕ РЕКОРДЫ:
${JSON.stringify(personalRecords, null, 2)}
` : `
USER PROFILE:
${userProfile ? `
- Weight: ${userProfile.weight || 'not specified'} kg
- Height: ${userProfile.height || 'not specified'} cm
- Gender: ${userProfile.gender || 'not specified'}
` : 'Profile not filled'}
HISTORY (last 8 sessions):
${JSON.stringify(workoutSummary, null, 2)}
PERSONAL RECORDS:
${JSON.stringify(personalRecords, null, 2)}
`;
};
export const generateWorkoutPlan = async (
userPrompt: string,
availableExercises: string[],
lang: 'en' | 'ru' = 'en',
durationMinutes: number = 60,
equipment: 'none' | 'essentials' | 'free_weights' | 'full_gym' = 'full_gym'
equipment: 'none' | 'essentials' | 'free_weights' | 'full_gym' = 'full_gym',
level: 'beginner' | 'intermediate' | 'advanced' = 'intermediate',
intensity: 'low' | 'moderate' | 'high' = 'moderate',
history: WorkoutSession[] = [],
userProfile?: UserProfile
): Promise<AIPlanResponse> => {
const equipmentDescription = t(`ai_equipment_desc_${equipment}` as any, lang);
const levelText = t(`level_${level}` as any, lang);
const intensityText = t(`intensity_${intensity}` as any, lang);
const userContext = getUserContextString(history, userProfile, lang);
const durationText = durationMinutes >= 120
? (lang === 'ru' ? '2+ часа' : '2+ hours')
: `${durationMinutes} ${lang === 'ru' ? 'минут' : 'minutes'}`;
const systemInstruction = lang === 'ru'
? `Ты — генератор планов тренировок. Верни ТОЛЬКО JSON-объект (без дополнительного текста).
? `Ты — генератор планов тренировок. Верни ТОЛЬКО JSON-объект.
ПАРАМЕТРЫ ТРЕНИРОВКИ:
${userContext}
ПАРАМЕТРЫ ПЛАНА:
- Длительность: ${durationText}
- Уровень: ${levelText}
- Интенсивность: ${intensityText}
- Оборудование: ${equipmentDescription}
ВАЖНО: Каждый элемент массива exercises — это ОДИН подход. Если нужно 3 подхода одного упражнения, оно должно встретиться в списке 3 раза подряд.
ВАЖНО:
1. Используй историю тренировок и рекорды, чтобы подобрать подходящие веса (или указать их как ориентир в описании) и сложность.
2. Проанализируй последние тренировки, чтобы не перегружать недавно работавшие мышцы.
3. Каждый элемент массива exercises — это ОДИН подход. Если нужно 3 подхода одного упражнения, оно должно встретиться в списке 3 раза подряд.
4. Если план "Full Body" — задействуй ВСЕ крупные мышечные группы.
ПРАВИЛА НАЗВАНИЙ (ОЧЕНЬ ВАЖНО):
1. "Weighted" (с отягощением) — это параметр isWeighted: true, а БОЛЬШЕ НИКОГДА НЕ часть названия.
- НЕЛЬЗЯ: "Weighted Pull-ups", "Подтягивания (с весом)"
- ПРАВИЛЬНО: "Подтягивания" (isWeighted: true)
2. СТРОГО БЕЗ ВАРИАНТОВ ИЛИ ОПЦИЙ.
- НЕЛЬЗЯ: "Отжимания (или на коленях)", "Ring Dips (or Barbell)"
- ПРАВИЛЬНО: "Отжимания на кольцах"
3. СТРОГО БЕЗ ИНСТРУКЦИЙ ПО ТЕХНИКЕ И СКОБОК.
- НЕЛЬЗЯ: "Подтягивания (до груди)", "Squats (ass to grass)"
- ПРАВИЛЬНО: "Подтягивания", "Приседания"
4. Названия должны быть КОРОТКИМИ и ЧИСТЫМИ.
Формат ответа:
{
"name": "Название плана",
"description": "Описание/инструкции по подготовке",
"description": "Описание/инструкции по подготовке (укажи, почему этот план подходит пользователю на основе его истории)",
"exercises": [
{ "name": "Название упражнения", "isWeighted": false, "restTimeSeconds": 60 }
]
@@ -189,18 +229,38 @@ export const generateWorkoutPlan = async (
Доступные упражнения: ${JSON.stringify(availableExercises)}
`
: `You are a workout plan generator. Return ONLY a JSON object (no extra text).
: `You are a workout plan generator. Return ONLY a JSON object.
${userContext}
WORKOUT PARAMETERS:
- Duration: ${durationText}
- Level: ${levelText}
- Intensity: ${intensityText}
- Equipment: ${equipmentDescription}
IMPORTANT: Each item in the exercises array represents ONE set. If you need 3 sets of an exercise, it must appear in the list 3 times consecutively.
IMPORTANT:
1. Use workout history and records to adjust difficulty and suggest weights (in description).
2. Analyze recent sessions to avoid overworking recently trained muscles.
3. Each item in the exercises array represents ONE set. If you need 3 sets of an exercise, it must appear in the list 3 times consecutively.
4. "Full Body" means ALL big muscle groups must be involved.
EXTREMELY IMPORTANT NAMING RULES:
1. "Weighted" must be a parameter (isWeighted: true), NEVER part of the name.
- BAD: "Weighted Pull-ups", "Pull-ups (Weighted)"
- GOOD: "Pull-ups" (isWeighted: true)
2. STRICTLY NO VARIANTS OR ALTERNATIVES.
- BAD: "Ring Dips (or Barbell Dips)", "Push-ups (or Kneeling)"
- GOOD: "Ring Dips"
3. STRICTLY NO FORM NOTES OR PARENTHESES with instructions.
- BAD: "Ring Rows (Chest to Rings)", "Squats (ass to grass)"
- GOOD: "Ring Rows", "Squats"
4. Keep names CLEAN and SHORT.
Response format:
{
"name": "Plan Name",
"description": "Description/preparation instructions",
"description": "Description/preparation instructions (explain why this fits based on user history)",
"exercises": [
{ "name": "Exercise Name", "isWeighted": false, "restTimeSeconds": 60 }
]
@@ -209,8 +269,7 @@ If an exercise is NOT in the available list, also add:
"type": one of STRENGTH, BODYWEIGHT, CARDIO, STATIC, PLYOMETRIC,
"unilateral": true or false
Available exercises: ${JSON.stringify(availableExercises)}
`;
Available exercises: ${JSON.stringify(availableExercises)}`;
const res = await api.post<ApiResponse<{ response: string }>>('/ai/chat', {
systemInstruction,

View File

@@ -140,9 +140,17 @@ const translations = {
ai_generated_plan: 'Generated Plan',
regenerate: 'Regenerate',
ai_equipment_desc_none: 'No equipment - bodyweight exercises only',
ai_equipment_desc_essentials: 'Street workout essentials - pull-up bar, dip bar, gymnastics rings',
ai_equipment_desc_essentials: 'Street workout essentials - pull-up bar, parallel bars, gymnastics rings',
ai_equipment_desc_free_weights: 'Free weights - dumbbells, kettlebells, barbells',
ai_equipment_desc_full_gym: 'Complete gym - all machines and equipment available',
level: 'Level',
level_beginner: 'Beginner',
level_intermediate: 'Intermediate',
level_advanced: 'Advanced',
intensity: 'Intensity',
intensity_low: 'Low',
intensity_moderate: 'Moderate',
intensity_high: 'High',
// Stats
progress: 'Progress',
@@ -349,6 +357,14 @@ const translations = {
ai_equipment_desc_essentials: 'Street workout - турник, брусья, кольца',
ai_equipment_desc_free_weights: 'Свободные веса - гантели, гири, штанга',
ai_equipment_desc_full_gym: 'Полный зал - все тренажеры и оборудование',
level: 'Уровень',
level_beginner: 'Новичок',
level_intermediate: 'Средний',
level_advanced: 'Продвинутый',
intensity: 'Интенсивность',
intensity_low: 'Низкая',
intensity_moderate: 'Средняя',
intensity_high: 'Высокая',
// Stats
progress: 'Прогресс',

View File

@@ -0,0 +1,106 @@
import { test, expect } from './fixtures';
import { randomUUID } from 'crypto';
test.describe('AI Plan Creation', () => {
test('2.14 A. Workout Plans - Create Plan with AI (Parametrized)', async ({ page, createUniqueUser }) => {
const user = await loginAndSetup(page, createUniqueUser);
// Mock the AI endpoint
await page.route('**/api/ai/chat', async route => {
const plan = {
name: 'AI Advanced Plan',
description: 'Generated High Intensity Plan',
exercises: [
{ name: 'Mock Push-ups', isWeighted: false, restTimeSeconds: 60, type: 'BODYWEIGHT', unilateral: false },
{ name: 'Mock Weighted Pull-ups', isWeighted: true, restTimeSeconds: 90, type: 'BODYWEIGHT', unilateral: false }
]
};
// The service expects { response: "string_or_json" }
await route.fulfill({
json: {
response: JSON.stringify(plan)
}
});
});
// Navigate to Plans
await page.getByRole('button', { name: 'Plans' }).first().click();
// Click FAB to open menu
const fab = page.getByLabel('Create Plan').or(page.getByRole('button', { name: '+' }));
await fab.click();
// Click "With AI"
await page.getByRole('button', { name: 'With AI' }).click();
// Verify Defaults
await expect(page.getByText('Create Plan with AI')).toBeVisible();
// Equipment default: No equipment
// Checking visual state might be hard if it's custom styled, but we can check the selected button style or text
const eqSection = page.locator('div').filter({ hasText: 'Equipment' }).last();
// Assuming "No equipment" is selected. We can check class or aria-pressed if available, or just proceed to change it.
// Level default: Intermediate
const levelSection = page.locator('div').filter({ hasText: 'Level' }).last();
// Just verify buttons exist
await expect(levelSection.getByRole('button', { name: 'Intermediate' })).toBeVisible();
// Intensity default: Moderate
const intensitySection = page.locator('div').filter({ hasText: 'Intensity' }).last();
await expect(intensitySection.getByRole('button', { name: 'Moderate' })).toBeVisible();
// Modify Inputs
// Change Level to Advanced
await levelSection.getByRole('button', { name: 'Advanced' }).click();
// Change Intensity to High
await intensitySection.getByRole('button', { name: 'High' }).click();
// Change Equipment to Free Weights
await eqSection.getByRole('button', { name: /Free weights/i }).click();
// Click Generate
await page.getByRole('button', { name: 'Generate' }).click();
// Verify Preview
// Wait for preview to appear (mock response)
await expect(page.getByText('Generated Plan')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Mock Push-ups')).toBeVisible();
await expect(page.getByText('Mock Weighted Pull-ups')).toBeVisible();
// Icons check (weighted vs bodyweight) - optional visual check
// Click Save Plan
await page.getByRole('button', { name: 'Save Plan' }).click();
// Verify Saved
// Should close sheet and show plan list
await expect(page.getByText('AI Advanced Plan')).toBeVisible();
await expect(page.getByText('Generated High Intensity Plan')).toBeVisible();
});
});
async function loginAndSetup(page: any, createUniqueUser: any) {
const user = await createUniqueUser();
await page.goto('/');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Login' }).click();
try {
const heading = page.getByRole('heading', { name: /Change Password/i });
const dashboard = page.getByText('Free Workout');
await expect(heading.or(dashboard)).toBeVisible({ timeout: 5000 });
if (await heading.isVisible()) {
await page.getByLabel('New Password').fill('StrongNewPass123!');
await page.getByRole('button', { name: /Save|Change/i }).click();
await expect(dashboard).toBeVisible();
}
} catch (e) {
// Login might already be done or dashboard loaded fast
}
return user;
}