diff --git a/packages/backend/src/db/queries/workout-plans.ts b/packages/backend/src/db/queries/workout-plans.ts index a07dd4c..739e19d 100644 --- a/packages/backend/src/db/queries/workout-plans.ts +++ b/packages/backend/src/db/queries/workout-plans.ts @@ -37,7 +37,7 @@ const ADJUSTMENT_COLUMNS = ` // --------------------------------------------------------------------------- /** Maps a raw DB row to a WorkoutPlan. */ -export function mapPlanRow(r: Record): WorkoutPlan { +function mapPlanRow(r: Record): WorkoutPlan { return { id: String(r['id']), userId: String(r['user_id']), @@ -53,7 +53,7 @@ export function mapPlanRow(r: Record): WorkoutPlan { } /** Maps a raw DB row to a PlanVersion (including JSONB data round-trip). */ -export function mapVersionRow(r: Record): PlanVersion { +function mapVersionRow(r: Record): PlanVersion { return { id: String(r['id']), planId: String(r['plan_id']), @@ -76,7 +76,7 @@ export function mapVersionRow(r: Record): PlanVersion { } /** Maps a raw DB row to a PlanAdjustment (including JSONB evidence round-trip). */ -export function mapAdjustmentRow(r: Record): PlanAdjustment { +function mapAdjustmentRow(r: Record): PlanAdjustment { const exerciseRef = typeof r['exercise_ref'] === 'object' && r['exercise_ref'] !== null ? (r['exercise_ref'] as ExerciseRef) diff --git a/packages/backend/src/routes/__tests__/workout-plans.test.ts b/packages/backend/src/routes/__tests__/workout-plans.test.ts index 43dc4a9..37e5492 100644 --- a/packages/backend/src/routes/__tests__/workout-plans.test.ts +++ b/packages/backend/src/routes/__tests__/workout-plans.test.ts @@ -45,9 +45,6 @@ vi.mock('../../db/queries/workout-plans.js', () => ({ bulkUpdateAdjustmentStatus: vi.fn().mockResolvedValue(undefined), insertAdjustment: vi.fn().mockResolvedValue({}), insertAdjustmentBatchWithAdjustments: vi.fn().mockResolvedValue('batch-uuid'), - mapPlanRow: vi.fn(), - mapVersionRow: vi.fn(), - mapAdjustmentRow: vi.fn(), })); vi.mock('../../services/workout-plans/plan-parser.js', () => ({ diff --git a/packages/backend/src/services/ai/report-generator.ts b/packages/backend/src/services/ai/report-generator.ts index 6d3aa99..b8be553 100644 --- a/packages/backend/src/services/ai/report-generator.ts +++ b/packages/backend/src/services/ai/report-generator.ts @@ -16,6 +16,7 @@ import { getLatestReport, saveReport, logAiGeneration } from '../../db/queries/r import { promoteActionItems, listActionItems } from '../../db/queries/action-items.js'; import { jsonrepair } from 'jsonrepair'; import { buildReportPrompt } from './prompt-builder.js'; +import { completeWithRetry } from './retry-utils.js'; import { measureOutcomes, determineOutcome } from '../action-items/outcome-measurer.js'; import { expireStaleItems, supersedeItems } from '../action-items/lifecycle-manager.js'; import { runCorrelationAnalysis } from '../intelligence/correlation-engine.js'; @@ -184,31 +185,6 @@ function countDistinctDays(dates: string[]): number { return new Set(dates.map((d) => d.split('T')[0])).size; } -async function completeWithRetry( - aiProvider: AIProvider, - messages: Parameters[0], - maxRetries = 3, -): ReturnType { - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await aiProvider.complete(messages); - } catch (err: unknown) { - const isRateLimit = - (err instanceof Error && /429|rate.limit|too many requests/i.test(err.message)) || - (typeof err === 'object' && - err !== null && - 'status' in err && - (err as { status: number }).status === 429); - - if (!isRateLimit || attempt === maxRetries) throw err; - - const delay = Math.min(1000 * 2 ** attempt, 30_000); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - throw new Error('Unreachable'); -} - export interface GatherAndGenerateResult { summary: string; insights: string; diff --git a/packages/backend/src/services/ai/retry-utils.ts b/packages/backend/src/services/ai/retry-utils.ts new file mode 100644 index 0000000..0518ba5 --- /dev/null +++ b/packages/backend/src/services/ai/retry-utils.ts @@ -0,0 +1,30 @@ +import type { AIProvider } from '@vitals/shared'; + +/** + * Retries an AI provider call with exponential backoff on 429 rate-limit errors. + * Shared between report-generator and plan-tuner (extracted from both to eliminate duplication). + */ +export async function completeWithRetry( + aiProvider: AIProvider, + messages: Parameters[0], + maxRetries = 3, +): ReturnType { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await aiProvider.complete(messages); + } catch (err: unknown) { + const isRateLimit = + (err instanceof Error && /429|rate.limit|too many requests/i.test(err.message)) || + (typeof err === 'object' && + err !== null && + 'status' in err && + (err as { status: number }).status === 429); + + if (!isRateLimit || attempt === maxRetries) throw err; + + const delay = Math.min(1000 * 2 ** attempt, 30_000); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw new Error('Unreachable'); +} diff --git a/packages/backend/src/services/workout-plans/__tests__/plan-parser.test.ts b/packages/backend/src/services/workout-plans/__tests__/plan-parser.test.ts index 7a4bdf7..305839d 100644 --- a/packages/backend/src/services/workout-plans/__tests__/plan-parser.test.ts +++ b/packages/backend/src/services/workout-plans/__tests__/plan-parser.test.ts @@ -73,6 +73,23 @@ Bench Press 3x8-12 @ 70kg`; expect(reps[1]).toBe(12); }); + it('S-tier exercises get progressionRule "linear", others get "double"', () => { + const text = `Push +Bench Press 3x5 @ 100kg +Tricep Pushdown 3x12`; + + const result = parseFreeTextPlan(text); + const day = result.days[0]; + const bench = day.exercises.find((e) => e.exerciseName.toLowerCase().includes('bench')); + const pushdown = day.exercises.find((e) => e.exerciseName.toLowerCase().includes('pushdown')); + expect(bench).toBeDefined(); + expect(pushdown).toBeDefined(); + // Bench press is S-tier → linear (2-for-2 rule) + expect(bench!.progressionRule).toBe('linear'); + // Tricep pushdown is A/B/C-tier → double progression + expect(pushdown!.progressionRule).toBe('double'); + }); + it('exercises with RPE targets (e.g. "3×5 @RPE 8") → targetRpe is 8', () => { const text = `Push Bench Press 3x5 @RPE 8`; diff --git a/packages/backend/src/services/workout-plans/__tests__/tuner.test.ts b/packages/backend/src/services/workout-plans/__tests__/tuner.test.ts index 155e174..fc8e992 100644 --- a/packages/backend/src/services/workout-plans/__tests__/tuner.test.ts +++ b/packages/backend/src/services/workout-plans/__tests__/tuner.test.ts @@ -12,9 +12,6 @@ vi.mock('../../../db/queries/workout-plans.js', () => ({ insertAdjustmentBatchWithAdjustments: vi.fn().mockResolvedValue('batch-uuid'), getAdjustmentBatch: vi.fn(), listAdjustmentsForBatch: vi.fn().mockResolvedValue([]), - mapAdjustmentRow: vi.fn(), - mapPlanRow: vi.fn(), - mapVersionRow: vi.fn(), })); vi.mock('../../../db/queries/reports.js', () => ({ diff --git a/packages/backend/src/services/workout-plans/plan-parser.ts b/packages/backend/src/services/workout-plans/plan-parser.ts index 13309de..7580dad 100644 --- a/packages/backend/src/services/workout-plans/plan-parser.ts +++ b/packages/backend/src/services/workout-plans/plan-parser.ts @@ -1,6 +1,15 @@ -import type { PlanData, PlanDay, PlanExercise, PlanSet } from '@vitals/shared'; +import type { PlanData, PlanDay, PlanExercise, PlanSet, ProgressionRule, SfrTier } from '@vitals/shared'; import { getExerciseMeta } from './exercise-metadata.js'; +/** + * Infer progressionRule from SFR tier. + * S-tier compounds (bench, squat, deadlift) → 'linear' (2-for-2 rule). + * A/B/C-tier accessories/isolation → 'double' (double-progression). + */ +function inferProgressionRule(sfrTier: SfrTier): ProgressionRule { + return sfrTier === 'S' ? 'linear' : 'double'; +} + // --------------------------------------------------------------------------- // Regex patterns // --------------------------------------------------------------------------- @@ -87,7 +96,7 @@ function parseExerciseLine(line: string, order: number): PlanExercise | null { exerciseName, orderInDay: order, sets, - progressionRule: 'double', + progressionRule: inferProgressionRule(meta.sfrTier), primaryMuscle: meta.primaryMuscle, secondaryMuscles: meta.secondaryMuscles, pattern: meta.pattern, diff --git a/packages/backend/src/services/workout-plans/tuner.ts b/packages/backend/src/services/workout-plans/tuner.ts index c6fc622..fc29bbc 100644 --- a/packages/backend/src/services/workout-plans/tuner.ts +++ b/packages/backend/src/services/workout-plans/tuner.ts @@ -24,6 +24,7 @@ import { applyMaxChangeRatio } from './rules/safety-caps.js'; import { buildTunePrompt } from './tuner-prompt-builder.js'; import { validatePlanData } from './plan-schema.js'; import { flagSuspiciousInput } from '../ai/conversation-service.js'; +import { completeWithRetry } from '../ai/retry-utils.js'; // --------------------------------------------------------------------------- // Types for AI output @@ -151,31 +152,6 @@ function validateTunerOutput( // completeWithRetry (mirrors report-generator.ts) // --------------------------------------------------------------------------- -async function completeWithRetry( - aiProvider: AIProvider, - messages: Parameters[0], - maxRetries = 3, -): ReturnType { - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await aiProvider.complete(messages); - } catch (err: unknown) { - const isRateLimit = - (err instanceof Error && /429|rate.limit|too many requests/i.test(err.message)) || - (typeof err === 'object' && - err !== null && - 'status' in err && - (err as { status: number }).status === 429); - - if (!isRateLimit || attempt === maxRetries) throw err; - - const delay = Math.min(1000 * 2 ** attempt, 30_000); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - throw new Error('Unreachable'); -} - // --------------------------------------------------------------------------- // Map AI selections to PlanAdjustment fields // --------------------------------------------------------------------------- diff --git a/packages/frontend/src/components/plan/AdjustmentReviewModal.tsx b/packages/frontend/src/components/plan/AdjustmentReviewModal.tsx index 21588ab..7d55a56 100644 --- a/packages/frontend/src/components/plan/AdjustmentReviewModal.tsx +++ b/packages/frontend/src/components/plan/AdjustmentReviewModal.tsx @@ -40,17 +40,25 @@ const CHANGE_TYPE_COLORS: Record = { add: 'bg-green-500/15 text-green-700 dark:text-green-400', }; +function formatSets(sets: Record[]): string { + const s = sets[0]; + const reps = Array.isArray(s['targetReps']) + ? `${s['targetReps'][0]}–${s['targetReps'][1]}` + : String(s['targetReps'] ?? '?'); + const load = s['targetWeightKg'] !== undefined ? `${s['targetWeightKg']} kg` : 'BW'; + return `${sets.length}×${reps} @ ${load}`; +} + function formatValue(value: unknown): string { if (!value || typeof value !== 'object') return String(value ?? '—'); + // PlanSet[] — direct array from tuner oldValue/newValue + if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'object') { + return formatSets(value as Record[]); + } + // Legacy { sets: [...] } wrapper (defensive) const v = value as Record; - const sets = v['sets']; - if (Array.isArray(sets) && sets.length > 0) { - const s = sets[0] as Record; - const reps = Array.isArray(s['targetReps']) - ? `${s['targetReps'][0]}–${s['targetReps'][1]}` - : String(s['targetReps'] ?? '?'); - const load = s['targetWeightKg'] !== undefined ? `${s['targetWeightKg']} kg` : 'BW'; - return `${sets.length}×${reps} @ ${load}`; + if (Array.isArray(v['sets']) && v['sets'].length > 0) { + return formatSets(v['sets'] as Record[]); } return JSON.stringify(value); }