132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
/**
|
|
* POST /api/pbl/v2/evaluate
|
|
*
|
|
* Three-in-one SSE entry point for PBL v2 evaluations. The client
|
|
* picks the kind via the request body:
|
|
*
|
|
* { kind: 'task', milestoneId, microtaskId, project }
|
|
* { kind: 'milestone', milestoneId, project }
|
|
* { kind: 'final', project }
|
|
*
|
|
* Same wire shape as `/api/pbl/v2/instructor`: SSE stream of
|
|
* PBLSSEEvent, ending with a final `done` after a `project_patch`
|
|
* carrying the new PBLEvaluation. Client wraps the evaluation into
|
|
* either a task-eval card (in-chat), a milestone+handover card
|
|
* (chat), or the completion page (separate UI).
|
|
*
|
|
* The route is stateless: the client owns the project clone and
|
|
* sends it in the body. The server mutates the clone and the patch
|
|
* event tells the client what changed. Same pattern as instructor
|
|
* route — see `app/api/pbl/v2/instructor/route.ts`.
|
|
*
|
|
* Why kind-as-body not kind-as-URL: the three runs all need the
|
|
* same model resolution, headers, project decoding, and SSE
|
|
* heartbeat plumbing — keeping them on one route avoids three
|
|
* near-identical handlers. The trade-off is a runtime switch on a
|
|
* string, which is cheap and obvious.
|
|
*/
|
|
|
|
import type { NextRequest } from 'next/server';
|
|
|
|
import { createLogger } from '@/lib/logger';
|
|
import { apiError } from '@/lib/server/api-response';
|
|
import { resolveModelFromRequest } from '@/lib/server/resolve-model';
|
|
|
|
import { createSSEResponse } from '@/lib/pbl/v2/api/sse';
|
|
import {
|
|
runFinalEvaluation,
|
|
runMilestoneEvaluation,
|
|
runTaskEvaluation,
|
|
} from '@/lib/pbl/v2/agents/evaluator';
|
|
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
|
|
|
|
export const maxDuration = 300;
|
|
|
|
const log = createLogger('PBL v2 Evaluate API');
|
|
|
|
type EvalKind = 'task' | 'milestone' | 'final';
|
|
|
|
interface EvaluateRequest {
|
|
project: PBLProjectV2;
|
|
kind: EvalKind;
|
|
milestoneId?: string;
|
|
microtaskId?: string;
|
|
recentChatSummary?: string;
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let body: EvaluateRequest;
|
|
try {
|
|
body = (await req.json()) as EvaluateRequest;
|
|
} catch {
|
|
return apiError('INVALID_REQUEST', 400, 'Request body must be valid JSON.');
|
|
}
|
|
|
|
if (!body?.project) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, '`project` is required.');
|
|
}
|
|
if (body.kind !== 'task' && body.kind !== 'milestone' && body.kind !== 'final') {
|
|
return apiError('INVALID_REQUEST', 400, "`kind` must be 'task' | 'milestone' | 'final'.");
|
|
}
|
|
if (body.kind === 'task' && (!body.milestoneId || !body.microtaskId)) {
|
|
return apiError(
|
|
'MISSING_REQUIRED_FIELD',
|
|
400,
|
|
"kind='task' requires both milestoneId and microtaskId.",
|
|
);
|
|
}
|
|
if (body.kind === 'milestone' && !body.milestoneId) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, "kind='milestone' requires milestoneId.");
|
|
}
|
|
|
|
let resolved;
|
|
try {
|
|
resolved = await resolveModelFromRequest(req, body, 'pbl-v2-runtime:evaluate');
|
|
} catch (err) {
|
|
log.error('Model resolution failed:', err);
|
|
return apiError('INVALID_REQUEST', 400, err instanceof Error ? err.message : String(err));
|
|
}
|
|
const { model, thinkingConfig, modelInfo } = resolved;
|
|
const hasVision = !!modelInfo?.capabilities?.vision;
|
|
|
|
if (body.kind === 'task') {
|
|
return createSSEResponse(
|
|
runTaskEvaluation({
|
|
project: body.project,
|
|
milestoneId: body.milestoneId!,
|
|
microtaskId: body.microtaskId!,
|
|
languageModel: model,
|
|
thinkingConfig,
|
|
recentChatSummary: body.recentChatSummary,
|
|
hasVision,
|
|
signal: req.signal,
|
|
}),
|
|
{ signal: req.signal },
|
|
);
|
|
}
|
|
if (body.kind === 'milestone') {
|
|
return createSSEResponse(
|
|
runMilestoneEvaluation({
|
|
project: body.project,
|
|
milestoneId: body.milestoneId!,
|
|
languageModel: model,
|
|
thinkingConfig,
|
|
recentChatSummary: body.recentChatSummary,
|
|
signal: req.signal,
|
|
}),
|
|
{ signal: req.signal },
|
|
);
|
|
}
|
|
// final
|
|
return createSSEResponse(
|
|
runFinalEvaluation({
|
|
project: body.project,
|
|
languageModel: model,
|
|
thinkingConfig,
|
|
recentChatSummary: body.recentChatSummary,
|
|
signal: req.signal,
|
|
}),
|
|
{ signal: req.signal },
|
|
);
|
|
}
|