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
+19 -1
View File
@@ -7,6 +7,10 @@ import {
PROGRAM_OUTPUT_SHAPE, PROGRAM_OUTPUT_SHAPE,
parseAIProgram, parseAIProgram,
} from '@/lib/ai/programSchema'; } from '@/lib/ai/programSchema';
import {
buildHistorySummary,
formatHistoryContext,
} from '@/lib/ai/historyContext';
/** /**
* POST /api/ai/generate * POST /api/ai/generate
@@ -33,6 +37,13 @@ import {
const bodySchema = z.object({ const bodySchema = z.object({
templateId: z.string().optional().nullable(), templateId: z.string().optional().nullable(),
userInput: z.string().min(1), userInput: z.string().min(1),
/**
* When true, build + append a compact summary of the user's
* recent (90-day) workout history to the system prompt. Lets the
* model design around stagnations, current strength levels, and
* actual training frequency.
*/
includeHistory: z.boolean().optional().default(false),
}); });
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -135,6 +146,13 @@ export async function POST(request: NextRequest) {
})), })),
); );
// If requested, build the workout-history summary block.
let historyBlock = '';
if (parsed.data.includeHistory) {
const summary = await buildHistorySummary(prisma, user.id);
historyBlock = formatHistoryContext(summary);
}
// Stitch the final system + user prompts. // Stitch the final system + user prompts.
const baseSystem = template?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; const baseSystem = template?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
const systemPrompt = `${baseSystem} const systemPrompt = `${baseSystem}
@@ -143,7 +161,7 @@ OUTPUT SHAPE — emit ONLY a JSON object matching this shape (no commentary, no
${PROGRAM_OUTPUT_SHAPE} ${PROGRAM_OUTPUT_SHAPE}
LIBRARY — pick exerciseId values from this list when possible. If you need an exercise the user doesn't have, set exerciseId to null and put the proposed name in exerciseName; the user will resolve it during preview. LIBRARY — pick exerciseId values from this list when possible. If you need an exercise the user doesn't have, set exerciseId to null and put the proposed name in exerciseName; the user will resolve it during preview.
${libraryJson}`; ${libraryJson}${historyBlock}`;
const userPromptBody = const userPromptBody =
template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ?? template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ??
+110
View File
@@ -0,0 +1,110 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getProvider } from '@/lib/ai/providers';
/**
* POST /api/ai/test
*
* Sends a tiny "say hi in 3 words" prompt to the user's currently
* configured AI provider and reports success/failure inline. Lets
* the operator validate provider/model/key/baseUrl without going
* through a full program generation.
*
* Returns:
* { ok: true, sample: "Hello there friend", tokensIn?, tokensOut?, ms }
* { ok: false, error: "..." }
*
* Times out after 30s — long enough for cold Ollama starts, short
* enough that a hung connection doesn't hang the UI.
*/
const TEST_TIMEOUT_MS = 30_000;
export async function POST() {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
}
const prefs = await prisma.userPreferences.findUnique({
where: { userId: user.id },
select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true },
});
if (!prefs?.aiProvider || !prefs?.aiModel) {
return NextResponse.json(
{
ok: false,
error: 'Pick a provider + model in Settings → AI integration first.',
},
{ status: 400 },
);
}
const provider = getProvider(prefs.aiProvider);
if (!provider) {
return NextResponse.json(
{ ok: false, error: `Unknown provider: ${prefs.aiProvider}` },
{ status: 400 },
);
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
const t0 = Date.now();
let sample = '';
let tokensIn: number | undefined;
let tokensOut: number | undefined;
let providerError: string | null = null;
try {
for await (const chunk of provider.generate({
apiKey: prefs.aiApiKey,
baseUrl: prefs.aiBaseUrl,
model: prefs.aiModel,
systemPrompt:
'You are a connectivity test. Reply with exactly three words: "Hello there friend." Nothing else.',
userPrompt: 'Say hi.',
signal: controller.signal,
})) {
if (chunk.type === 'text') sample += chunk.delta;
else if (chunk.type === 'usage') {
tokensIn = chunk.tokensIn;
tokensOut = chunk.tokensOut;
} else if (chunk.type === 'error') {
providerError = chunk.message;
}
}
} catch (e) {
providerError =
controller.signal.aborted
? `Timed out after ${Math.round(TEST_TIMEOUT_MS / 1000)}s`
: (e as Error).message;
} finally {
clearTimeout(timer);
}
const ms = Date.now() - t0;
if (providerError) {
return NextResponse.json({ ok: false, error: providerError, ms }, { status: 200 });
}
if (!sample.trim()) {
return NextResponse.json(
{
ok: false,
error:
'Got an empty response. The model returned successfully but with no text — check the model name and try again.',
ms,
},
{ status: 200 },
);
}
return NextResponse.json({
ok: true,
sample: sample.trim().slice(0, 200),
tokensIn,
tokensOut,
ms,
});
}
+5 -1
View File
@@ -11,7 +11,7 @@ export default async function GeneratePage() {
const user = await getCurrentUser(); const user = await getCurrentUser();
if (!user) redirect('/auth/login'); if (!user) redirect('/auth/login');
const [templates, exercises, prefs] = await Promise.all([ const [templates, exercises, prefs, workoutCount] = await Promise.all([
prisma.aIPromptTemplate.findMany({ prisma.aIPromptTemplate.findMany({
where: { OR: [{ userId: null }, { userId: user.id }] }, where: { OR: [{ userId: null }, { userId: user.id }] },
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }], orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
@@ -31,6 +31,9 @@ export default async function GeneratePage() {
where: { userId: user.id }, where: { userId: user.id },
select: { aiProvider: true, aiModel: true }, select: { aiProvider: true, aiModel: true },
}), }),
prisma.workout.count({
where: { userId: user.id, deletedAt: null },
}),
]); ]);
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel; const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
@@ -74,6 +77,7 @@ export default async function GeneratePage() {
exercises={exercises} exercises={exercises}
providerLabel={prefs!.aiProvider!} providerLabel={prefs!.aiProvider!}
modelLabel={prefs!.aiModel!} modelLabel={prefs!.aiModel!}
workoutCount={workoutCount}
/> />
)} )}
</div> </div>
+89 -7
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader2, Sparkles, Square } from 'lucide-react'; import { Loader2, Sparkles, Square } from 'lucide-react';
import { lenientJsonParse } from '@/lib/ai/lenientJson';
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -52,7 +53,7 @@ interface AIProgram {
type Phase = type Phase =
| { kind: 'idle' } | { kind: 'idle' }
| { kind: 'streaming'; raw: string } | { kind: 'streaming'; raw: string; partial: Partial<AIProgram> | null }
| { kind: 'parsed'; raw: string; program: AIProgram } | { kind: 'parsed'; raw: string; program: AIProgram }
| { kind: 'failed'; raw: string; message: string }; | { kind: 'failed'; raw: string; message: string };
@@ -61,15 +62,18 @@ export default function GenerateClient({
exercises, exercises,
providerLabel, providerLabel,
modelLabel, modelLabel,
workoutCount,
}: { }: {
templates: Template[]; templates: Template[];
exercises: LibraryExercise[]; exercises: LibraryExercise[];
providerLabel: string; providerLabel: string;
modelLabel: string; modelLabel: string;
workoutCount: number;
}) { }) {
const router = useRouter(); const router = useRouter();
const [templateId, setTemplateId] = useState(templates[0]?.id ?? ''); const [templateId, setTemplateId] = useState(templates[0]?.id ?? '');
const [userInput, setUserInput] = useState(''); const [userInput, setUserInput] = useState('');
const [includeHistory, setIncludeHistory] = useState(workoutCount >= 10);
const [generationId, setGenerationId] = useState<string | null>(null); const [generationId, setGenerationId] = useState<string | null>(null);
const [phase, setPhase] = useState<Phase>({ kind: 'idle' }); const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
const [tokens, setTokens] = useState<{ in?: number; out?: number }>({}); const [tokens, setTokens] = useState<{ in?: number; out?: number }>({});
@@ -82,7 +86,7 @@ export default function GenerateClient({
const handleGenerate = async () => { const handleGenerate = async () => {
if (!userInput.trim()) return; if (!userInput.trim()) return;
setPhase({ kind: 'streaming', raw: '' }); setPhase({ kind: 'streaming', raw: '', partial: null });
setGenerationId(null); setGenerationId(null);
setTokens({}); setTokens({});
@@ -95,6 +99,7 @@ export default function GenerateClient({
body: JSON.stringify({ body: JSON.stringify({
templateId: templateId || null, templateId: templateId || null,
userInput, userInput,
includeHistory,
}), }),
signal: abortRef.current.signal, signal: abortRef.current.signal,
}); });
@@ -146,7 +151,8 @@ export default function GenerateClient({
setGenerationId(parsed.id); setGenerationId(parsed.id);
} else if (evtName === 'text') { } else if (evtName === 'text') {
raw += parsed.delta; raw += parsed.delta;
setPhase({ kind: 'streaming', raw }); const partial = lenientJsonParse(raw) as Partial<AIProgram> | null;
setPhase({ kind: 'streaming', raw, partial });
} else if (evtName === 'usage') { } else if (evtName === 'usage') {
setTokens({ in: parsed.tokensIn, out: parsed.tokensOut }); setTokens({ in: parsed.tokensIn, out: parsed.tokensOut });
} else if (evtName === 'complete') { } else if (evtName === 'complete') {
@@ -256,6 +262,25 @@ export default function GenerateClient({
/> />
</Field> </Field>
<label className="flex items-start gap-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={includeHistory}
onChange={(e) => setIncludeHistory(e.target.checked)}
disabled={phase.kind === 'streaming' || workoutCount === 0}
className="mt-0.5"
/>
<span>
Include my workout history as context{' '}
<span className="text-zinc-500">
({workoutCount === 0
? 'no workouts logged yet — disabled'
: `last 90 days · summarizes per-exercise frequency, recent weights, stagnations`}
)
</span>
</span>
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{phase.kind === 'streaming' ? ( {phase.kind === 'streaming' ? (
<button <button
@@ -294,10 +319,23 @@ export default function GenerateClient({
</div> </div>
{phase.kind === 'streaming' && ( {phase.kind === 'streaming' && (
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 font-mono text-[11px] text-zinc-400 max-h-80 overflow-auto whitespace-pre-wrap"> <>
{phase.raw || '(waiting for first token...)'} {phase.partial ? (
<Loader2 className="inline w-3 h-3 animate-spin ml-2" /> <PartialPreview partial={phase.partial} />
</div> ) : (
<div className="text-xs text-zinc-500 italic">
Waiting for the first parseable JSON...
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
</div>
)}
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Raw stream</summary>
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 font-mono text-[11px] text-zinc-400 max-h-80 overflow-auto whitespace-pre-wrap mt-2">
{phase.raw || '(waiting for first token...)'}
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
</div>
</details>
</>
)} )}
{phase.kind === 'failed' && ( {phase.kind === 'failed' && (
@@ -613,3 +651,47 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
</label> </label>
); );
} }
function PartialPreview({ partial }: { partial: Partial<AIProgram> }) {
const weeks = (partial.weeks as AIWeek[] | undefined) ?? [];
return (
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Loader2 className="w-3 h-3 animate-spin text-zinc-500" />
<span className="text-zinc-400">
Building program...{' '}
{partial.name && (
<span className="text-white font-semibold">{partial.name}</span>
)}
{partial.type && (
<span className="text-zinc-500"> · {partial.type}</span>
)}
{typeof partial.durationWeeks === 'number' && (
<span className="text-zinc-500"> · {partial.durationWeeks} wk</span>
)}
</span>
</div>
{weeks.length > 0 && (
<ul className="text-xs text-zinc-300 space-y-1">
{weeks.map((w, i) => (
<li key={i}>
<span className="text-zinc-500">Week {w?.weekNumber ?? '?'}:</span>{' '}
{Array.isArray(w?.days)
? `${w.days.length} day${w.days.length === 1 ? '' : 's'} (${
w.days.reduce(
(n: number, d: AIDay) =>
n + (Array.isArray(d?.exercises) ? d.exercises.length : 0),
0,
)
} exercises)`
: '...'}
{w?.phase && (
<span className="text-zinc-500"> · {w.phase}</span>
)}
</li>
))}
</ul>
)}
</div>
);
}
+49 -2
View File
@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Trash2, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'; import { Trash2, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { estimateCost, formatCost } from '@/lib/ai/pricing';
interface Row { interface Row {
id: string; id: string;
@@ -26,6 +27,34 @@ export default function HistoryList({
const [rows, setRows] = useState(initialRows); const [rows, setRows] = useState(initialRows);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
// Per-row cost + 30-day rolling total. Pricing is best-effort
// (Ollama = free, openai-compatible = unknown, others priced
// from lib/ai/pricing.ts). Free + unknown both contribute 0 to
// the total so it's a lower bound at worst.
const rowsWithCost = useMemo(
() =>
rows.map((r) => ({
...r,
costUsd: estimateCost({
provider: r.provider,
model: r.model,
tokensIn: r.tokensIn,
tokensOut: r.tokensOut,
}),
})),
[rows],
);
const totalLast30Days = useMemo(() => {
const cutoff = Date.now() - 30 * 86_400_000;
let total = 0;
for (const r of rowsWithCost) {
if (r.costUsd != null && new Date(r.createdAt).getTime() >= cutoff) {
total += r.costUsd;
}
}
return total;
}, [rowsWithCost]);
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('Delete this generation? The applied Program (if any) stays.')) if (!confirm('Delete this generation? The applied Program (if any) stays.'))
return; return;
@@ -48,8 +77,17 @@ export default function HistoryList({
} }
return ( return (
<>
<p className="text-xs text-zinc-500 mb-4 uppercase tracking-wider">
Estimated cost (last 30 days):{' '}
<span className="text-zinc-200">{formatCost(totalLast30Days)}</span>
<span className="text-zinc-600">
{' '}
· Ollama + custom-URL gateways excluded
</span>
</p>
<ul className="space-y-3"> <ul className="space-y-3">
{rows.map((r) => ( {rowsWithCost.map((r) => (
<li <li
key={r.id} key={r.id}
className="bg-zinc-900 border border-zinc-800 rounded p-4" className="bg-zinc-900 border border-zinc-800 rounded p-4"
@@ -71,6 +109,14 @@ export default function HistoryList({
</span> </span>
</> </>
)} )}
{r.costUsd != null && (
<>
<span className="text-zinc-600">·</span>
<span title="Estimated USD cost based on the model's published per-token pricing">
{formatCost(r.costUsd)}
</span>
</>
)}
</div> </div>
{r.templateName && ( {r.templateName && (
<p className="text-xs text-zinc-400 mt-1"> <p className="text-xs text-zinc-400 mt-1">
@@ -111,6 +157,7 @@ export default function HistoryList({
</li> </li>
))} ))}
</ul> </ul>
</>
); );
} }
@@ -29,6 +29,18 @@ export default function AIIntegration() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<
| null
| {
ok: true;
sample: string;
tokensIn?: number;
tokensOut?: number;
ms: number;
}
| { ok: false; error: string; ms?: number }
>(null);
useEffect(() => { useEffect(() => {
fetch('/api/ai/config') fetch('/api/ai/config')
@@ -44,6 +56,20 @@ export default function AIIntegration() {
const meta = PROVIDERS.find((p) => p.id === provider); const meta = PROVIDERS.find((p) => p.id === provider);
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await fetch('/api/ai/test', { method: 'POST' });
const body = await res.json();
setTestResult(body);
} catch (e) {
setTestResult({ ok: false, error: (e as Error).message });
} finally {
setTesting(false);
}
};
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -186,21 +212,64 @@ export default function AIIntegration() {
</div> </div>
)} )}
<button <div className="flex items-center gap-2">
type="button" <button
onClick={handleSave} type="button"
disabled={saving} onClick={handleSave}
className="px-4 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500" disabled={saving || testing}
> className="px-4 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500"
{saving ? ( >
<> {saving ? (
<Loader2 className="inline w-4 h-4 animate-spin mr-2" /> <>
Saving... <Loader2 className="inline w-4 h-4 animate-spin mr-2" />
</> Saving...
) : ( </>
'Save AI config' ) : (
'Save AI config'
)}
</button>
{provider && cfg?.aiProvider === provider && cfg?.aiModel && (
<button
type="button"
onClick={handleTest}
disabled={saving || testing}
className="px-4 py-2 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs uppercase tracking-wider disabled:opacity-50"
title="Send a tiny prompt to verify the configured provider responds"
>
{testing ? (
<>
<Loader2 className="inline w-3.5 h-3.5 animate-spin mr-2" />
Testing...
</>
) : (
'Test connection'
)}
</button>
)} )}
</button> </div>
{testResult && (
<div
className={`rounded px-3 py-2 border text-xs ${
testResult.ok
? 'bg-emerald-900/40 border-emerald-800 text-emerald-300'
: 'bg-red-900/50 border-red-800 text-red-400'
}`}
>
{testResult.ok ? (
<>
Connected in {testResult.ms}ms
{testResult.tokensIn != null &&
` · ${testResult.tokensIn} in / ${testResult.tokensOut ?? '?'} out tokens`}
<div className="mt-1 text-zinc-400">
Sample reply: <span className="text-zinc-200">{testResult.sample}</span>
</div>
</>
) : (
<> {testResult.error}</>
)}
</div>
)}
</div> </div>
</section> </section>
); );
+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');
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Lenient JSON parser for incremental rendering of in-flight LLM
* output.
*
* The model emits JSON one token at a time. Strict JSON.parse fails
* until the very last `}` arrives. lenientJsonParse instead:
*
* 1. Locates the first `{` (after stripping ```json fences).
* 2. Walks the buffer tracking quote state + an open-bracket
* stack so we know what to close in what order.
* 3. Closes any open string with `"`.
* 4. Trims a partial trailing keyword (true/false/null prefix),
* trailing comma, and dangling key:value pair where value is
* missing.
* 5. Closes open structures in reverse-of-opening order (so
* `[{` closes as `}]`, not `]}`).
* 6. JSON.parse the result; return null if it still fails.
*
* The returned object is a best-effort snapshot of the program so
* far. The Generate UI uses it to render a live preview as the
* model writes; once the stream ends, the FULL response is parsed
* with the strict parser via parseAIProgram for the final render.
*
* This is intentionally simple — partial numbers (e.g. `-2.`) and
* partial escape sequences just return null until the next chunk
* makes them well-formed.
*/
export function lenientJsonParse(raw: string): unknown | null {
if (!raw) return null;
// Strip ```json fences (or plain ``` fences). Tolerates an
// unclosed trailing fence (still streaming).
let s = raw;
const fenced = s.match(/```(?:json)?\s*([\s\S]*?)(?:\s*```|$)/);
if (fenced) s = fenced[1];
// Locate first `{`.
const startIdx = s.indexOf('{');
if (startIdx < 0) return null;
s = s.slice(startIdx);
// Quick path: maybe it's already valid (rare during streaming,
// common after the stream completes).
try {
return JSON.parse(s);
} catch {
// fall through
}
// Walk the buffer tracking the open-bracket stack. We don't try
// to recover from mismatched closers (would be model malformity);
// we just don't pop more than we have.
const stack: Array<'{' | '['> = [];
let inStr = false;
let escape = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (escape) {
escape = false;
continue;
}
if (c === '\\') {
escape = true;
continue;
}
if (c === '"') {
inStr = !inStr;
continue;
}
if (inStr) continue;
if (c === '{') stack.push('{');
else if (c === '}') {
if (stack[stack.length - 1] === '{') stack.pop();
} else if (c === '[') stack.push('[');
else if (c === ']') {
if (stack[stack.length - 1] === '[') stack.pop();
}
}
let candidate = s;
// Close any open string at the tail.
if (inStr) candidate += '"';
// Trim trailing whitespace.
candidate = candidate.replace(/\s+$/, '');
// Drop a partial trailing keyword (`true`/`false`/`null` prefix)
// sitting after a `:`, `,`, or `[`.
candidate = candidate.replace(
/([:,[])\s*(?:t|tr|tru|f|fa|fal|fals|n|nu|nul)$/,
'$1',
);
// Drop a trailing comma (no value follows yet).
candidate = candidate.replace(/,\s*$/, '');
// Drop a dangling key + colon (value not started yet).
candidate = candidate.replace(/"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*$/, '');
// Drop another trailing comma that may now be exposed.
candidate = candidate.replace(/,\s*$/, '');
// Close stack in reverse-of-opening order. `[{` becomes `}]` not
// `]}` — that's the bug a depth-counter approach would have.
while (stack.length > 0) {
const top = stack.pop()!;
candidate += top === '{' ? '}' : ']';
}
try {
return JSON.parse(candidate);
} catch {
return null;
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Per-model pricing in USD per million tokens. Used to estimate the
* cost of an AIGeneration row from its tokensIn/tokensOut.
*
* Prices change. This table is a best-effort starting point for
* common models as of mid-2026; users on other models will see
* `null` cost (we still surface token counts). Updating: edit this
* file and ship — no schema change needed.
*
* Matching strategy: case-insensitive prefix lookup against the
* user's configured model string. Model names like
* "claude-sonnet-4-5-20251022" match the "claude-sonnet-4-5" prefix.
*
* Keys are organized by provider for readability but the lookup is
* provider-agnostic — the model string is the key.
*/
interface PriceEntry {
inputPerM: number; // USD per 1M input tokens
outputPerM: number; // USD per 1M output tokens
}
const PRICES: Record<string, PriceEntry> = {
// Anthropic Claude (Messages API)
'claude-opus-4': { inputPerM: 15, outputPerM: 75 },
'claude-opus-4-5': { inputPerM: 15, outputPerM: 75 },
'claude-sonnet-4': { inputPerM: 3, outputPerM: 15 },
'claude-sonnet-4-5': { inputPerM: 3, outputPerM: 15 },
'claude-haiku-4': { inputPerM: 0.8, outputPerM: 4 },
'claude-haiku-4-5': { inputPerM: 0.8, outputPerM: 4 },
'claude-3-7-sonnet': { inputPerM: 3, outputPerM: 15 },
'claude-3-5-sonnet': { inputPerM: 3, outputPerM: 15 },
'claude-3-5-haiku': { inputPerM: 0.8, outputPerM: 4 },
// OpenAI
'gpt-5': { inputPerM: 1.25, outputPerM: 10 },
'gpt-5-mini': { inputPerM: 0.25, outputPerM: 2 },
'gpt-5-nano': { inputPerM: 0.05, outputPerM: 0.4 },
'gpt-4o': { inputPerM: 2.5, outputPerM: 10 },
'gpt-4o-mini': { inputPerM: 0.15, outputPerM: 0.6 },
'o1': { inputPerM: 15, outputPerM: 60 },
'o3': { inputPerM: 2, outputPerM: 8 },
'o3-mini': { inputPerM: 1.1, outputPerM: 4.4 },
'o4-mini': { inputPerM: 1.1, outputPerM: 4.4 },
// Google Gemini
'gemini-2.5-pro': { inputPerM: 1.25, outputPerM: 10 },
'gemini-2.5-flash': { inputPerM: 0.3, outputPerM: 2.5 },
'gemini-2.0-flash': { inputPerM: 0.1, outputPerM: 0.4 },
'gemini-2.0-pro': { inputPerM: 1.25, outputPerM: 5 },
'gemini-1.5-pro': { inputPerM: 1.25, outputPerM: 5 },
'gemini-1.5-flash': { inputPerM: 0.075, outputPerM: 0.3 },
};
/** Find the price entry whose key is a (case-insensitive) prefix of the model string. */
export function findPrice(model: string): PriceEntry | null {
const m = model.toLowerCase();
// Longest-prefix-first so e.g. "claude-sonnet-4-5" beats "claude-sonnet-4".
const sortedKeys = Object.keys(PRICES).sort((a, b) => b.length - a.length);
for (const key of sortedKeys) {
if (m.startsWith(key.toLowerCase())) return PRICES[key];
}
return null;
}
/**
* Estimate the USD cost of a generation. Returns null if the model
* isn't in the price table or if either token count is missing.
* Ollama and openai-compatible custom gateways always return null
* (they're either free or self-priced).
*/
export function estimateCost(opts: {
provider: string;
model: string;
tokensIn: number | null;
tokensOut: number | null;
}): number | null {
if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost
if (opts.provider === 'openai-compatible') return null; // we don't know the gateway's pricing
if (opts.tokensIn == null || opts.tokensOut == null) return null;
const price = findPrice(opts.model);
if (!price) return null;
return (
(opts.tokensIn / 1_000_000) * price.inputPerM +
(opts.tokensOut / 1_000_000) * price.outputPerM
);
}
/** Format USD to a string suitable for a UI label. Below $0.01 -> "<$0.01". */
export function formatCost(usd: number | null): string {
if (usd == null) return '—';
if (usd === 0) return 'free';
if (usd < 0.01) return '<$0.01';
if (usd < 1) return `$${usd.toFixed(3)}`;
return `$${usd.toFixed(2)}`;
}
@@ -0,0 +1,224 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { prisma } from '@/lib/prisma';
import {
buildHistorySummary,
formatHistoryContext,
} from '@/lib/ai/historyContext';
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.setLog.deleteMany();
await prisma.workout.deleteMany();
await prisma.programExercise.deleteMany();
await prisma.programDay.deleteMany();
await prisma.programWeek.deleteMany();
await prisma.program.deleteMany();
await prisma.exercise.deleteMany();
await prisma.aIGeneration.deleteMany();
await prisma.aIPromptTemplate.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
});
async function setup() {
const user = await prisma.user.create({
data: { email: 'a@x', passwordHash: 'fake' },
});
const bench = await prisma.exercise.create({
data: {
userId: user.id,
name: 'Bench Press',
type: 'barbell',
muscleGroups: '[]',
},
});
const squat = await prisma.exercise.create({
data: {
userId: user.id,
name: 'Squat',
type: 'barbell',
muscleGroups: '[]',
},
});
return { user, bench, squat };
}
async function logWorkout(
userId: string,
daysAgo: number,
sets: Array<{ exerciseId: string; reps: number; weight: number; rpe?: number }>,
) {
const date = new Date(Date.now() - daysAgo * 86_400_000);
return prisma.workout.create({
data: {
userId,
date,
setLogs: {
create: sets.map((s, i) => ({
exerciseId: s.exerciseId,
setNumber: i + 1,
reps: s.reps,
weight: s.weight,
rpe: s.rpe,
})),
},
},
});
}
describe('buildHistorySummary', () => {
it('returns empty summary for a user with no workouts', async () => {
const user = await prisma.user.create({
data: { email: 'a@x', passwordHash: 'fake' },
});
const s = await buildHistorySummary(prisma, user.id);
expect(s.totalWorkouts).toBe(0);
expect(s.exercises).toEqual([]);
});
it('summarizes a single user\'s recent activity', async () => {
const { user, bench, squat } = await setup();
// 3 bench sessions, 2 squat sessions in last 30 days
await logWorkout(user.id, 1, [
{ exerciseId: bench.id, reps: 5, weight: 225 },
{ exerciseId: bench.id, reps: 5, weight: 225 },
]);
await logWorkout(user.id, 4, [
{ exerciseId: bench.id, reps: 5, weight: 235 },
{ exerciseId: bench.id, reps: 5, weight: 235 },
]);
await logWorkout(user.id, 7, [
{ exerciseId: bench.id, reps: 5, weight: 215 },
{ exerciseId: squat.id, reps: 5, weight: 315 },
]);
await logWorkout(user.id, 14, [
{ exerciseId: squat.id, reps: 5, weight: 305 },
]);
const s = await buildHistorySummary(prisma, user.id);
expect(s.totalWorkouts).toBe(4);
expect(s.workoutsPerWeek).toBeGreaterThan(0);
expect(s.exercises).toHaveLength(2);
const benchSummary = s.exercises.find((e) => e.name === 'Bench Press');
expect(benchSummary).toBeTruthy();
expect(benchSummary!.totalSets).toBe(5);
expect(benchSummary!.distinctWorkouts).toBe(3);
expect(benchSummary!.bestWeight).toBe(235);
expect(benchSummary!.daysSinceLast).toBeLessThanOrEqual(2); // logged 1 day ago
// Epley(235, 5) = 235 * (1 + 5/30) = 274.17 → 274
expect(benchSummary!.estimated1RM).toBe(274);
});
it('flags stagnation on a stuck exercise', async () => {
const { user, bench } = await setup();
// 6 sessions all at the same weight
for (let d = 0; d < 6; d++) {
await logWorkout(user.id, d * 5, [
{ exerciseId: bench.id, reps: 5, weight: 225 },
{ exerciseId: bench.id, reps: 5, weight: 225 },
]);
}
const s = await buildHistorySummary(prisma, user.id);
const bs = s.exercises.find((e) => e.name === 'Bench Press');
expect(bs?.stagnant).toBe(true);
});
it('does NOT flag stagnation on a progressing exercise', async () => {
const { user, bench } = await setup();
// 6 sessions with progressive weight
for (let d = 0; d < 6; d++) {
await logWorkout(user.id, (5 - d) * 7, [
{ exerciseId: bench.id, reps: 5, weight: 200 + d * 10 },
]);
}
const s = await buildHistorySummary(prisma, user.id);
const bs = s.exercises.find((e) => e.name === 'Bench Press');
expect(bs?.stagnant).toBe(false);
});
it('excludes workouts outside the window', async () => {
const { user, bench } = await setup();
await logWorkout(user.id, 5, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
await logWorkout(user.id, 200, [{ exerciseId: bench.id, reps: 5, weight: 200 }]);
const s = await buildHistorySummary(prisma, user.id, 90);
expect(s.totalWorkouts).toBe(1);
expect(s.exercises[0].totalSets).toBe(1);
});
it('excludes soft-deleted workouts', async () => {
const { user, bench } = await setup();
const w = await logWorkout(user.id, 3, [
{ exerciseId: bench.id, reps: 5, weight: 225 },
]);
await prisma.workout.update({
where: { id: w.id },
data: { deletedAt: new Date() },
});
const s = await buildHistorySummary(prisma, user.id);
expect(s.totalWorkouts).toBe(0);
});
it('isolates per-user data (does not bleed across users)', async () => {
const { user, bench } = await setup();
const otherUser = await prisma.user.create({
data: { email: 'b@x', passwordHash: 'fake' },
});
const otherBench = await prisma.exercise.create({
data: {
userId: otherUser.id,
name: 'Bench Press',
type: 'barbell',
muscleGroups: '[]',
},
});
await logWorkout(user.id, 1, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
await logWorkout(otherUser.id, 1, [
{ exerciseId: otherBench.id, reps: 100, weight: 999 },
]);
const s = await buildHistorySummary(prisma, user.id);
expect(s.totalWorkouts).toBe(1);
expect(s.exercises[0].bestWeight).toBe(225); // not 999
});
});
describe('formatHistoryContext', () => {
it('emits a friendly message on empty history', () => {
const out = formatHistoryContext({
windowDays: 90,
totalWorkouts: 0,
workoutsPerWeek: 0,
primaryTypes: [],
exercises: [],
});
expect(out).toMatch(/no workouts/);
});
it('formats a populated summary into a compact block', () => {
const out = formatHistoryContext({
windowDays: 90,
totalWorkouts: 30,
workoutsPerWeek: 3.3,
primaryTypes: ['barbell', 'dumbbell', 'cable'],
exercises: [
{
name: 'Bench Press',
type: 'barbell',
totalSets: 36,
distinctWorkouts: 12,
daysSinceLast: 2,
lastWeight: 235,
lastReps: 5,
bestWeight: 245,
estimated1RM: 286,
avgRpe: 8.5,
stagnant: false,
},
],
});
expect(out).toMatch(/30 workouts/);
expect(out).toMatch(/Bench Press/);
expect(out).toMatch(/STAGNANT|RPE/);
});
});
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { lenientJsonParse } from '@/lib/ai/lenientJson';
describe('lenientJsonParse', () => {
it('returns null on empty input', () => {
expect(lenientJsonParse('')).toBeNull();
});
it('returns null when no { is present', () => {
expect(lenientJsonParse('hello world')).toBeNull();
});
it('returns the object when input is already valid', () => {
expect(lenientJsonParse('{"a":1,"b":[2,3]}')).toEqual({ a: 1, b: [2, 3] });
});
it('strips ```json fences', () => {
expect(lenientJsonParse('```json\n{"a":1}\n```')).toEqual({ a: 1 });
});
it('handles fences not yet closed (still streaming)', () => {
expect(lenientJsonParse('```json\n{"a":1, "b":2')).toEqual({ a: 1, b: 2 });
});
it('finds the first { after preamble', () => {
expect(lenientJsonParse('Here you go:\n{"name":"x"}')).toEqual({
name: 'x',
});
});
it('auto-closes a partial object missing its closing }', () => {
const got = lenientJsonParse('{"name":"X","durationWeeks":4');
expect(got).toEqual({ name: 'X', durationWeeks: 4 });
});
it('auto-closes a partial array missing its closing ]', () => {
const got = lenientJsonParse('{"weeks":[1,2,3');
expect(got).toEqual({ weeks: [1, 2, 3] });
});
it('drops a dangling property key with no value yet', () => {
const got = lenientJsonParse('{"name":"X","notes":');
expect(got).toEqual({ name: 'X' });
});
it('drops a trailing comma after a complete value', () => {
const got = lenientJsonParse('{"a":1,"b":2,');
expect(got).toEqual({ a: 1, b: 2 });
});
it('handles a partial nested structure typical of AI program output', () => {
const partial = `{
"name": "Test",
"type": "hypertrophy",
"durationWeeks": 4,
"weeks": [
{
"weekNumber": 1,
"days": [
{
"dayOfWeek": 1,
"name": "Push",
"exercises": [
{"exerciseId": "abc", "exerciseName": "Bench", "order": 0, "sets": 4
`;
const got = lenientJsonParse(partial) as Record<string, any>;
expect(got).toBeTruthy();
expect(got.name).toBe('Test');
expect(Array.isArray(got.weeks)).toBe(true);
expect(got.weeks[0].weekNumber).toBe(1);
// The dangling exercise object may or may not be present
// depending on truncation; what matters is the parser didn't
// throw.
});
it('handles an open string at the end', () => {
const got = lenientJsonParse('{"description":"A long descrip');
expect(got).toBeTruthy();
expect((got as Record<string, string>).description).toMatch(
/^A long descrip/,
);
});
it('returns null for unrecoverable garbage', () => {
// Mismatched closing brace before any opening is unrecoverable
expect(lenientJsonParse('}}}')).toBeNull();
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import { findPrice, estimateCost, formatCost } from '@/lib/ai/pricing';
describe('findPrice', () => {
it('matches a known model exactly', () => {
expect(findPrice('claude-sonnet-4-5')).toBeTruthy();
});
it('matches a known model with a date suffix (longest-prefix)', () => {
const p = findPrice('claude-sonnet-4-5-20251022');
expect(p?.inputPerM).toBe(3);
expect(p?.outputPerM).toBe(15);
});
it('is case-insensitive', () => {
expect(findPrice('GPT-5-Mini')).toBeTruthy();
});
it('returns null for unknown models', () => {
expect(findPrice('mistral-medium-9000')).toBeNull();
});
it('prefers longer-prefix when multiple keys match', () => {
// claude-sonnet-4-5 is more specific than claude-sonnet-4
const p = findPrice('claude-sonnet-4-5');
expect(p).toEqual({ inputPerM: 3, outputPerM: 15 });
});
});
describe('estimateCost', () => {
it('returns 0 for ollama (self-hosted)', () => {
expect(
estimateCost({
provider: 'ollama',
model: 'llama3.1:8b',
tokensIn: 1000,
tokensOut: 500,
}),
).toBe(0);
});
it('returns null for openai-compatible (unknown gateway pricing)', () => {
expect(
estimateCost({
provider: 'openai-compatible',
model: 'meta-llama/llama-3.1-8b-instruct',
tokensIn: 1000,
tokensOut: 500,
}),
).toBeNull();
});
it('returns null when the model isn\'t in the price table', () => {
expect(
estimateCost({
provider: 'claude',
model: 'claude-vintage-edition',
tokensIn: 1000,
tokensOut: 500,
}),
).toBeNull();
});
it('returns null when token counts are missing', () => {
expect(
estimateCost({
provider: 'claude',
model: 'claude-sonnet-4-5',
tokensIn: null,
tokensOut: 500,
}),
).toBeNull();
});
it('computes the right $ for a known model', () => {
// claude-sonnet-4-5: $3/M in, $15/M out
// 100K in + 50K out = 0.1*3 + 0.05*15 = 0.3 + 0.75 = 1.05
const cost = estimateCost({
provider: 'claude',
model: 'claude-sonnet-4-5',
tokensIn: 100_000,
tokensOut: 50_000,
});
expect(cost).toBeCloseTo(1.05, 5);
});
it('computes correctly for gpt-5-nano (very cheap)', () => {
// gpt-5-nano: $0.05/M in, $0.4/M out
// 1000 in + 500 out = 0.001*0.05 + 0.0005*0.4 = 0.00005 + 0.0002 = 0.00025
const cost = estimateCost({
provider: 'openai',
model: 'gpt-5-nano',
tokensIn: 1000,
tokensOut: 500,
});
expect(cost).toBeCloseTo(0.00025, 8);
});
});
describe('formatCost', () => {
it('formats null as em dash', () => {
expect(formatCost(null)).toBe('—');
});
it('formats 0 as "free"', () => {
expect(formatCost(0)).toBe('free');
});
it('formats sub-cent costs as "<$0.01"', () => {
expect(formatCost(0.0023)).toBe('<$0.01');
});
it('formats sub-dollar costs with 3 decimal places', () => {
expect(formatCost(0.123)).toBe('$0.123');
});
it('formats dollar+ costs with 2 decimal places', () => {
expect(formatCost(2.567)).toBe('$2.57');
});
});
+5 -1
View File
@@ -8,6 +8,7 @@ import { v_1_0_0_6 } from './v1.0.0.6'
import { v_1_0_0_7 } from './v1.0.0.7' import { v_1_0_0_7 } from './v1.0.0.7'
import { v_1_1_0_1 } from './v1.1.0.1' import { v_1_1_0_1 } from './v1.1.0.1'
import { v_1_1_0_2 } from './v1.1.0.2' import { v_1_1_0_2 } from './v1.1.0.2'
import { v_1_1_0_3 } from './v1.1.0.3'
/** /**
* Version graph for the `proof-of-work` package. * Version graph for the `proof-of-work` package.
@@ -25,9 +26,11 @@ import { v_1_1_0_2 } from './v1.1.0.2'
* v1.1.0:1 — Programs UI (manual create / save / follow). * v1.1.0:1 — Programs UI (manual create / save / follow).
* v1.1.0:2 — AI program generation, 5 providers (Claude / OpenAI / * v1.1.0:2 — AI program generation, 5 providers (Claude / OpenAI /
* OpenAI-compatible / Gemini / Ollama). * OpenAI-compatible / Gemini / Ollama).
* v1.1.0:3 — AI upgrades: history-as-context, test connection,
* cost estimator, streaming preview render.
*/ */
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_1_1_0_2, current: v_1_1_0_3,
other: [ other: [
v_1_0_0_1, v_1_0_0_1,
v_1_0_0_2, v_1_0_0_2,
@@ -37,5 +40,6 @@ export const versionGraph = VersionGraph.of({
v_1_0_0_6, v_1_0_0_6,
v_1_0_0_7, v_1_0_0_7,
v_1_1_0_1, v_1_1_0_1,
v_1_1_0_2,
], ],
}) })
+61
View File
@@ -0,0 +1,61 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.1.0:3 — AI: workout-history context, test connection,
* cost estimator, streaming preview render.
*
* History context (the killer feature)
* - lib/ai/historyContext.ts builds a compact 90-day rollup of
* the user's training: per-exercise frequency, recent weights,
* estimated 1RMs (Epley), avg RPE, days-since-last, plus a
* STAGNANT flag when the heaviest weight in the new half of
* the window doesn't beat the old half.
* - Generate page has an "Include my workout history as context"
* checkbox (default on if you have ≥10 logged workouts). When
* checked, the summary is appended to the system prompt so the
* model can recommend things like "you've stalled bench at 245
* for 6 weeks — try paused reps."
* - The summary is text, ~1-3 KB even for heavy users. We
* deliberately don't ship raw set logs (privacy + token cost).
*
* Test connection
* - POST /api/ai/test sends a tiny "say hi in 3 words" prompt
* against the user's configured provider and reports
* latency + first sample of the response, or the error inline.
* - "Test connection" button next to "Save AI config" in
* Settings → AI integration. Lets you verify the provider/
* model/key/baseUrl combo without going through full program
* generation.
*
* Cost estimator
* - lib/ai/pricing.ts ships a price table for the major models
* (Claude Sonnet/Opus/Haiku 4-5, GPT-5/4o/o3, Gemini 2.5/2.0).
* Ollama always returns 0 (self-hosted, no per-token cost).
* openai-compatible returns null (we don't know the gateway's
* pricing).
* - Generation history shows per-row cost + a 30-day rolling
* total at the top of the page.
*
* Streaming preview render
* - lib/ai/lenientJson.ts: a 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.
*
* No schema changes. /data is untouched.
*/
export const v_1_1_0_3 = VersionInfo.of({
version: '1.1.0:3',
releaseNotes: {
en_US:
'AI program generation gets four upgrades: (1) include your last 90 days of workout history as context — the model designs around your actual frequency, current weights, and stagnations; (2) "Test connection" button in Settings to verify provider/model/key without a full generation; (3) per-generation USD cost + 30-day rolling total in the history page (Ollama is free, openai-compatible gateways are unknown); (4) streaming preview renders the program tree as the model writes it instead of waiting for the full response. No data migration.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})