Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

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 },
);
}