Files
proof-of-work/proof-of-work/lib/ai/historyContext.ts
T
Keysat 8f149d35ab 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>
2026-05-10 22:17:35 -05:00

246 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}