Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/backend/src/db/queries/workout-plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const ADJUSTMENT_COLUMNS = `
// ---------------------------------------------------------------------------

/** Maps a raw DB row to a WorkoutPlan. */
export function mapPlanRow(r: Record<string, unknown>): WorkoutPlan {
function mapPlanRow(r: Record<string, unknown>): WorkoutPlan {
return {
id: String(r['id']),
userId: String(r['user_id']),
Expand All @@ -53,7 +53,7 @@ export function mapPlanRow(r: Record<string, unknown>): WorkoutPlan {
}

/** Maps a raw DB row to a PlanVersion (including JSONB data round-trip). */
export function mapVersionRow(r: Record<string, unknown>): PlanVersion {
function mapVersionRow(r: Record<string, unknown>): PlanVersion {
return {
id: String(r['id']),
planId: String(r['plan_id']),
Expand All @@ -76,7 +76,7 @@ export function mapVersionRow(r: Record<string, unknown>): PlanVersion {
}

/** Maps a raw DB row to a PlanAdjustment (including JSONB evidence round-trip). */
export function mapAdjustmentRow(r: Record<string, unknown>): PlanAdjustment {
function mapAdjustmentRow(r: Record<string, unknown>): PlanAdjustment {
const exerciseRef =
typeof r['exercise_ref'] === 'object' && r['exercise_ref'] !== null
? (r['exercise_ref'] as ExerciseRef)
Expand Down
3 changes: 0 additions & 3 deletions packages/backend/src/routes/__tests__/workout-plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
26 changes: 1 addition & 25 deletions packages/backend/src/services/ai/report-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AIProvider['complete']>[0],
maxRetries = 3,
): ReturnType<AIProvider['complete']> {
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;
Expand Down
30 changes: 30 additions & 0 deletions packages/backend/src/services/ai/retry-utils.ts
Original file line number Diff line number Diff line change
@@ -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<AIProvider['complete']>[0],
maxRetries = 3,
): ReturnType<AIProvider['complete']> {
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');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
13 changes: 11 additions & 2 deletions packages/backend/src/services/workout-plans/plan-parser.ts
Original file line number Diff line number Diff line change
@@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 1 addition & 25 deletions packages/backend/src/services/workout-plans/tuner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -151,31 +152,6 @@ function validateTunerOutput(
// completeWithRetry (mirrors report-generator.ts)
// ---------------------------------------------------------------------------

async function completeWithRetry(
aiProvider: AIProvider,
messages: Parameters<AIProvider['complete']>[0],
maxRetries = 3,
): ReturnType<AIProvider['complete']> {
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
// ---------------------------------------------------------------------------
Expand Down
24 changes: 16 additions & 8 deletions packages/frontend/src/components/plan/AdjustmentReviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,25 @@ const CHANGE_TYPE_COLORS: Record<ChangeType, string> = {
add: 'bg-green-500/15 text-green-700 dark:text-green-400',
};

function formatSets(sets: Record<string, unknown>[]): 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<string, unknown>[]);
}
// Legacy { sets: [...] } wrapper (defensive)
const v = value as Record<string, unknown>;
const sets = v['sets'];
if (Array.isArray(sets) && sets.length > 0) {
const s = sets[0] as Record<string, unknown>;
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<string, unknown>[]);
}
return JSON.stringify(value);
}
Expand Down
Loading