8f149d35ab
Four incremental upgrades to the AI program generator. No schema change, no /data migration.
1. History as context (the killer feature)
- lib/ai/historyContext.ts builds a 90-day per-exercise rollup:
frequency, recent weights, estimated 1RM (Epley), avg RPE,
days-since-last, plus a STAGNANT flag when the heaviest weight in
the new half doesn't beat the old half.
- Generate page surfaces an "Include my workout history as context"
checkbox (default on at >=10 logged workouts). When checked, the
~1-3 KB summary is appended to the system prompt so the model can
recommend things like "you've stalled bench at 245 — try paused reps."
- We deliberately don't ship raw set logs (privacy + token cost).
2. Test connection
- POST /api/ai/test sends a tiny "say hi in 3 words" prompt and
reports latency + first sample, or the error inline.
- "Test connection" button next to "Save AI config" in
Settings -> AI integration. Verifies provider/model/key/baseUrl
without going through full program generation.
3. Cost estimator
- lib/ai/pricing.ts ships a price table for major models
(Claude 3.5/3.7/4/4.5, GPT-4o/5/o1/o3/o4-mini, Gemini 1.5/2.0/2.5).
Ollama always returns 0; openai-compatible returns null.
- Generation history shows per-row cost + a 30-day rolling total
at the top of the page.
4. Streaming preview render
- lib/ai/lenientJson.ts: stack-aware partial-JSON parser that
auto-closes open strings/brackets/braces in reverse-of-opening
order, drops dangling key:value pairs and partial keywords.
Returns a best-effort snapshot of the program-so-far on each chunk.
- Generate UI now renders a live "Building program..." panel that
updates as weeks/days/exercises arrive instead of just showing
raw text and waiting for stream end.
Tests: 26 new (ai-historyContext.test.ts, ai-lenientJson.test.ts,
ai-pricing.test.ts). 161 total pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
246 lines
7.6 KiB
TypeScript
246 lines
7.6 KiB
TypeScript
import type { PrismaClient } from '@prisma/client';
|
||
|
||
/**
|
||
* Build a compact workout-history summary the AI can use as
|
||
* context for personalized program generation.
|
||
*
|
||
* We DELIBERATELY don't ship raw set logs — that would be tens of
|
||
* KB per request and burn tokens. Instead we compute per-exercise
|
||
* aggregates over a recent window (default 90 days):
|
||
*
|
||
* - totalSets in window
|
||
* - distinct workouts
|
||
* - daysSinceLast
|
||
* - lastWeight, lastReps (from the most-recent set)
|
||
* - bestWeight (heaviest set in window)
|
||
* - estimated 1RM (Epley formula on the heaviest weighted set)
|
||
* - rpe trend (avg RPE over recent sets, if logged)
|
||
* - stagnation flag (heaviest weight unchanged for 4+ weeks AND
|
||
* ≥3 sessions of that exercise in those 4+ weeks)
|
||
*
|
||
* Plus a top-level summary: total workouts, frequency, primary
|
||
* exercise types touched.
|
||
*
|
||
* The output is JSON-stringifiable, ~5-15 KB for a typical user.
|
||
*/
|
||
|
||
export interface HistoryExerciseSummary {
|
||
name: string;
|
||
type: string;
|
||
totalSets: number;
|
||
distinctWorkouts: number;
|
||
daysSinceLast: number;
|
||
lastWeight: number | null;
|
||
lastReps: number | null;
|
||
bestWeight: number | null;
|
||
estimated1RM: number | null;
|
||
avgRpe: number | null;
|
||
stagnant: boolean;
|
||
}
|
||
|
||
export interface HistorySummary {
|
||
windowDays: number;
|
||
totalWorkouts: number;
|
||
workoutsPerWeek: number;
|
||
primaryTypes: string[]; // exercise types by descending volume
|
||
exercises: HistoryExerciseSummary[];
|
||
}
|
||
|
||
/** Epley estimated 1RM: weight * (1 + reps / 30) */
|
||
function epley1RM(weight: number, reps: number): number {
|
||
return Math.round(weight * (1 + reps / 30));
|
||
}
|
||
|
||
export async function buildHistorySummary(
|
||
prisma: PrismaClient,
|
||
userId: string,
|
||
windowDays = 90,
|
||
): Promise<HistorySummary> {
|
||
const cutoff = new Date(Date.now() - windowDays * 86_400_000);
|
||
|
||
// Pull every set log in the window with its exercise + workout
|
||
// date. One query, one result-set walk.
|
||
const sets = await prisma.setLog.findMany({
|
||
where: {
|
||
workout: {
|
||
userId,
|
||
deletedAt: null,
|
||
date: { gte: cutoff },
|
||
},
|
||
},
|
||
select: {
|
||
reps: true,
|
||
weight: true,
|
||
rpe: true,
|
||
exerciseId: true,
|
||
workoutId: true,
|
||
workout: { select: { date: true } },
|
||
exercise: { select: { name: true, type: true } },
|
||
},
|
||
orderBy: { workout: { date: 'desc' } },
|
||
});
|
||
|
||
if (sets.length === 0) {
|
||
return {
|
||
windowDays,
|
||
totalWorkouts: 0,
|
||
workoutsPerWeek: 0,
|
||
primaryTypes: [],
|
||
exercises: [],
|
||
};
|
||
}
|
||
|
||
const workoutIds = new Set(sets.map((s) => s.workoutId));
|
||
const totalWorkouts = workoutIds.size;
|
||
const weeks = windowDays / 7;
|
||
const workoutsPerWeek = Math.round((totalWorkouts / weeks) * 10) / 10;
|
||
|
||
// Group by exercise
|
||
const byExercise = new Map<
|
||
string,
|
||
{
|
||
name: string;
|
||
type: string;
|
||
sets: typeof sets;
|
||
}
|
||
>();
|
||
for (const s of sets) {
|
||
if (!byExercise.has(s.exerciseId)) {
|
||
byExercise.set(s.exerciseId, {
|
||
name: s.exercise.name,
|
||
type: s.exercise.type,
|
||
sets: [],
|
||
});
|
||
}
|
||
byExercise.get(s.exerciseId)!.sets.push(s);
|
||
}
|
||
|
||
// Per-exercise summaries
|
||
const now = Date.now();
|
||
const exercises: HistoryExerciseSummary[] = [];
|
||
for (const [, group] of byExercise) {
|
||
const groupSets = group.sets;
|
||
const distinctWorkouts = new Set(groupSets.map((s) => s.workoutId)).size;
|
||
const mostRecent = groupSets[0]; // already date-desc
|
||
const daysSinceLast = Math.floor(
|
||
(now - mostRecent.workout.date.getTime()) / 86_400_000,
|
||
);
|
||
|
||
const weightedSets = groupSets.filter(
|
||
(s): s is typeof s & { weight: number; reps: number } =>
|
||
typeof s.weight === 'number' && typeof s.reps === 'number',
|
||
);
|
||
const bestWeightSet = weightedSets.reduce<
|
||
| { weight: number; reps: number }
|
||
| null
|
||
>((best, s) => {
|
||
if (!best || s.weight > best.weight) return s;
|
||
return best;
|
||
}, null);
|
||
const bestWeight = bestWeightSet?.weight ?? null;
|
||
const estimated1RM =
|
||
bestWeightSet != null ? epley1RM(bestWeightSet.weight, bestWeightSet.reps) : null;
|
||
|
||
const rpeSets = groupSets.filter(
|
||
(s): s is typeof s & { rpe: number } => typeof s.rpe === 'number',
|
||
);
|
||
const avgRpe =
|
||
rpeSets.length > 0
|
||
? Math.round(
|
||
(rpeSets.reduce((sum, s) => sum + s.rpe, 0) / rpeSets.length) * 10,
|
||
) / 10
|
||
: null;
|
||
|
||
// Stagnation: best weight in oldest half == best weight in newest half
|
||
// AND ≥3 distinct sessions in the window.
|
||
let stagnant = false;
|
||
if (distinctWorkouts >= 3 && bestWeight != null && weightedSets.length >= 4) {
|
||
const sortedByDate = [...weightedSets].sort(
|
||
(a, b) => a.workout.date.getTime() - b.workout.date.getTime(),
|
||
);
|
||
const mid = Math.floor(sortedByDate.length / 2);
|
||
const oldHalfBest = Math.max(...sortedByDate.slice(0, mid).map((s) => s.weight));
|
||
const newHalfBest = Math.max(...sortedByDate.slice(mid).map((s) => s.weight));
|
||
// No improvement in the new half compared to the old half
|
||
if (newHalfBest <= oldHalfBest) stagnant = true;
|
||
}
|
||
|
||
exercises.push({
|
||
name: group.name,
|
||
type: group.type,
|
||
totalSets: groupSets.length,
|
||
distinctWorkouts,
|
||
daysSinceLast,
|
||
lastWeight: mostRecent.weight ?? null,
|
||
lastReps: mostRecent.reps ?? null,
|
||
bestWeight,
|
||
estimated1RM,
|
||
avgRpe,
|
||
stagnant,
|
||
});
|
||
}
|
||
|
||
// Sort exercises by total volume (sets) descending so the most
|
||
// important context is first if the model truncates.
|
||
exercises.sort((a, b) => b.totalSets - a.totalSets);
|
||
|
||
// Primary types by aggregate sets
|
||
const typeVolume = new Map<string, number>();
|
||
for (const ex of exercises) {
|
||
typeVolume.set(ex.type, (typeVolume.get(ex.type) ?? 0) + ex.totalSets);
|
||
}
|
||
const primaryTypes = Array.from(typeVolume.entries())
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([t]) => t);
|
||
|
||
return {
|
||
windowDays,
|
||
totalWorkouts,
|
||
workoutsPerWeek,
|
||
primaryTypes,
|
||
exercises,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Format a HistorySummary as a compact string the LLM can actually
|
||
* use. Aims for <2KB of text even for heavy users.
|
||
*/
|
||
export function formatHistoryContext(summary: HistorySummary): string {
|
||
if (summary.totalWorkouts === 0) {
|
||
return `\nUSER HISTORY: no workouts logged in the last ${summary.windowDays} days.`;
|
||
}
|
||
const lines: string[] = [];
|
||
lines.push(
|
||
`\nUSER HISTORY (last ${summary.windowDays} days):`,
|
||
` ${summary.totalWorkouts} workouts (~${summary.workoutsPerWeek}/week)`,
|
||
` Primary work: ${summary.primaryTypes.slice(0, 4).join(', ')}`,
|
||
'',
|
||
` Per-exercise activity (descending by volume; weights in user's logged unit):`,
|
||
);
|
||
// Cap at top 30 exercises
|
||
const top = summary.exercises.slice(0, 30);
|
||
for (const ex of top) {
|
||
const bits: string[] = [
|
||
`${ex.totalSets}s/${ex.distinctWorkouts}w`,
|
||
`${ex.daysSinceLast}d ago`,
|
||
];
|
||
if (ex.bestWeight != null && ex.lastReps != null)
|
||
bits.push(`best ${ex.bestWeight}×${ex.lastReps}`);
|
||
if (ex.estimated1RM != null) bits.push(`~${ex.estimated1RM} 1RM`);
|
||
if (ex.avgRpe != null) bits.push(`avg RPE ${ex.avgRpe}`);
|
||
if (ex.stagnant) bits.push('STAGNANT');
|
||
lines.push(` - ${ex.name} (${ex.type}): ${bits.join(' · ')}`);
|
||
}
|
||
if (summary.exercises.length > top.length) {
|
||
lines.push(
|
||
` ...and ${summary.exercises.length - top.length} more exercises with lower volume`,
|
||
);
|
||
}
|
||
lines.push(
|
||
'',
|
||
` When designing the program, weight recent activity heavily. Address STAGNANT exercises if relevant. Don't propose deload-week-heavy work for someone training infrequently, and don't propose 6-day splits for someone averaging <3 sessions/week.`,
|
||
);
|
||
return lines.join('\n');
|
||
}
|