Files
proof-of-work/proof-of-work/app/api/ai/generate/route.ts
T
Keysat 2b0abad68e
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run
v1.2.0:6 — AI "generate today's workout" from a brain-dump
Add a single-session AI flow alongside program generation: describe a
workout in plain words and get a ready-to-log workout back — exercises
with suggested weights, target reps, and set counts grounded in the
user's recent history. The suggestion can be inline-edited or refined
by sending a follow-up instruction back to the model, then "Use this
workout" pre-fills the normal New Workout form (nothing persists until
the user saves through the regular path).

Why reuse, not fork: the existing program-generation spine (detached
background runner, SSE streaming, lenient-JSON preview, 5 providers,
history context, library name->id mapping) already does the hard parts.
A new AIGeneration.kind discriminant ("program" | "workout", default
"program" via boot-time guarded ALTER) selects the parser and keeps the
ephemeral workout rows out of the program-shaped AI history. Refine is a
fresh generation seeded with the prior suggestion (validated through the
same schema before it re-enters the prompt).

Hand-off is sessionStorage -> /main/workouts/new?from=ai -> AiWorkoutPrefill,
which expands each suggestion into N sets and maps effort by cardio-ness
(Gear for cardio, RPE for strength). EditWorkoutData.id is now optional so
the prefill CREATEs rather than PATCHing a nonexistent id. The AI suggests
each weight in that exercise's effective logging unit (the library JSON
carries a per-exercise unit) so the stored number and unit never diverge.

Built + sideloaded to immense-voyage.local as 1.2.0:6; on-box ALTER and
non-root launch confirmed via start-cli. tsc clean (app + packaging),
251 tests pass, next build + s9pk build succeed.
2026-06-19 10:59:12 -05:00

166 lines
4.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import {
PROGRAM_OUTPUT_SHAPE,
} from '@/lib/ai/programSchema';
import {
buildHistorySummary,
formatHistoryContext,
} from '@/lib/ai/historyContext';
import { buildBaseSystemPrompt } from '@/lib/ai/systemPromptBase';
import { kickoffGeneration } from '@/lib/ai/generationRunner';
/**
* POST /api/ai/generate
*
* Body: { templateId?: string, userInput: string, includeHistory?: boolean }
*
* v1.1.0:4: this endpoint now KICKS OFF a background runner and returns
* the new generation id immediately. The caller subscribes to live
* deltas via GET /api/ai/generations/[id]/stream (SSE) or polls via
* GET /api/ai/generations/[id]. Navigating away no longer cancels the
* generation — the runner keeps writing to the row in the background.
*
* Response:
* 201 { id: "...generationId..." }
* 400 { error: "..." }
*/
const bodySchema = z.object({
templateId: z.string().optional().nullable(),
userInput: z.string().min(1),
includeHistory: z.boolean().optional().default(false),
});
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid body', details: parsed.error.errors },
{ status: 400 },
);
}
const prefs = await prisma.userPreferences.findUnique({
where: { userId: user.id },
});
if (!prefs?.aiProvider || !prefs?.aiModel) {
return NextResponse.json(
{
error:
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
},
{ status: 400 },
);
}
// Load the template if provided.
let template:
| { id: string; name: string; systemPrompt: string; userPromptTemplate: string }
| null = null;
if (parsed.data.templateId) {
const t = await prisma.aIPromptTemplate.findFirst({
where: {
id: parsed.data.templateId,
OR: [{ userId: user.id }, { userId: null }],
},
select: {
id: true,
name: true,
systemPrompt: true,
userPromptTemplate: true,
},
});
if (!t) {
return NextResponse.json({ error: 'Template not found.' }, { status: 404 });
}
template = t;
}
// Library for the prompt.
const exercises = await prisma.exercise.findMany({
where: { userId: user.id },
select: { id: true, name: true, type: true, muscleGroups: true },
});
const libraryJson = JSON.stringify(
exercises.map((e) => ({
id: e.id,
name: e.name,
type: e.type,
muscleGroups: (() => {
try {
return JSON.parse(e.muscleGroups);
} catch {
return [];
}
})(),
})),
);
// History context if requested.
let historyBlock = '';
if (parsed.data.includeHistory) {
const summary = await buildHistorySummary(prisma, user.id);
historyBlock = formatHistoryContext(summary);
}
// v1.1.0:4 base prompt with output contract + weight rules. Stitched
// BEFORE the template's coaching philosophy so output rules win when
// they conflict.
const weightUnit = (prefs.defaultWeightUnit as 'lbs' | 'kg') || 'lbs';
const isLocalModel = prefs.aiProvider === 'ollama';
const basePrompt = buildBaseSystemPrompt({
weightUnit,
hasHistoryContext: parsed.data.includeHistory,
isLocalModel,
});
const templatePrompt = template?.systemPrompt ?? DEFAULT_TEMPLATE_PROMPT;
const systemPrompt = `${basePrompt}
# COACHING PHILOSOPHY (template-specific)
${templatePrompt}
# OUTPUT SHAPE
${PROGRAM_OUTPUT_SHAPE}
# LIBRARY (use these exerciseIds; do not invent ids)
${libraryJson}${historyBlock}`;
const userPromptBody =
template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ??
parsed.data.userInput;
const id = await kickoffGeneration({
prisma,
userId: user.id,
kind: 'program',
templateId: template?.id ?? null,
templateName: template?.name ?? null,
userInput: parsed.data.userInput,
systemPrompt,
userPrompt: userPromptBody,
provider: prefs.aiProvider,
model: prefs.aiModel,
apiKey: prefs.aiApiKey,
baseUrl: prefs.aiBaseUrl,
});
return NextResponse.json({ id }, { status: 201 });
}
const DEFAULT_TEMPLATE_PROMPT = `You are a strength and conditioning coach. The user will describe what they want; design a program that matches their goal, experience, equipment, and time budget. Pick exercises from the LIBRARY and stay close to evidence-based programming for the requested goal (hypertrophy / strength / power / conditioning / general fitness).`;