dba478aa23
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.
89 lines
2.8 KiB
TypeScript
89 lines
2.8 KiB
TypeScript
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();
|
|
});
|
|
});
|