v1.1.0:3 — AI upgrades: history context, test connection, cost estimator, streaming preview
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>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user