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:
Keysat
2026-05-10 22:17:35 -05:00
parent 974c3eb07d
commit 8f149d35ab
14 changed files with 1306 additions and 26 deletions
+245
View File
@@ -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');
}