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

163 lines
6.1 KiB
TypeScript

/**
* POST /api/pbl/v2/task/update
*
* Pure state-mutation endpoint for the workspace UI. Operates on the
* `PBLProjectV2` the client sends and returns the mutated project so
* the client can persist it locally.
*
* Actions:
* - `start` — mark a microtask in_progress (used when the
* learner clicks a sidebar microtask).
* - `continue_handover` — after a milestone wrap, click Continue to
* activate the next milestone's first task.
* - `complete_pending_task`
* — learner clicks the sidebar Done button after the
* current task reached the manual completion point.
*
* No LLM involvement. Stateless.
*/
export const maxDuration = 60;
import type { NextRequest } from 'next/server';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import {
startMicrotask,
continueAfterHandover,
currentMicrotask,
advanceMicrotask,
completeRoleplayAct,
appendTaskDividerMessage,
} from '@/lib/pbl/v2/operations/progress';
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
import { currentPendingTaskCompletion } from '@/lib/pbl/v2/operations/task-completion';
interface UpdateRequest {
project: PBLProjectV2;
action:
| 'start'
| 'continue_handover'
| 'enter_scenario'
| 'complete_act'
| 'complete_pending_task';
microtaskId?: string;
}
export async function POST(req: NextRequest) {
let body: UpdateRequest;
try {
body = (await req.json()) as UpdateRequest;
} catch {
return apiError('INVALID_REQUEST', 400, 'Request body must be valid JSON.');
}
if (!body?.project) {
return apiError('MISSING_REQUIRED_FIELD', 400, '`project` is required.');
}
const project = body.project;
switch (body.action) {
case 'start': {
if (!body.microtaskId) {
return apiError('MISSING_REQUIRED_FIELD', 400, '`microtaskId` is required for start.');
}
startMicrotask(project, body.microtaskId);
return apiSuccess({ project });
}
case 'continue_handover': {
const r = continueAfterHandover(project);
if (!r.ok) {
return apiError('INVALID_REQUEST', 400, 'No pending handover to consume.');
}
return apiSuccess({ project, activatedMicrotaskId: r.activatedMicrotaskId });
}
case 'complete_pending_task': {
const current = currentMicrotask(project);
if (!current) {
return apiError('INVALID_REQUEST', 400, 'No active microtask to complete.');
}
const pending = currentPendingTaskCompletion(project, current.microtask.id);
if (!pending) {
return apiError('INVALID_REQUEST', 400, 'No pending task completion to confirm.');
}
const adv = advanceMicrotask(
project,
current.microtask.id,
pending.reason,
pending.assessment ?? {},
);
if (!adv.ok) {
return apiError('INVALID_REQUEST', 400, `Could not complete task: ${adv.error}`);
}
const nextTask = adv.nextMicrotaskId
? current.milestone.microtasks.find((task) => task.id === adv.nextMicrotaskId)
: undefined;
appendTaskDividerMessage(project, {
completedMicrotaskId: current.microtask.id,
nextMicrotaskId: adv.nextMicrotaskId,
completedTitle: current.microtask.title,
nextTitle: nextTask?.title,
});
return apiSuccess({
project,
completedMicrotaskId: current.microtask.id,
milestoneId: current.milestone.id,
milestoneCompleted: adv.milestoneCompleted,
projectCompleted: adv.projectCompleted,
nextMicrotaskId: adv.nextMicrotaskId,
});
}
// SCENARIO ONLY. The learner clicked "enter scenario" under the prep
// stage in the sidebar. Deterministically complete the prep stage and
// cross into the (first) scene stage: this completes the prep
// microtask → seals the prep milestone + stages the handover →
// consumes the handover to activate the scene stage and emit the
// stage divider. No LLM, no milestone eval (prep is a pure intro).
// Strictly gated to a scenario prep stage; otherwise rejected so it
// can never affect ordinary projects.
case 'enter_scenario': {
if (!project.scenario) {
return apiError('INVALID_REQUEST', 400, 'Not a scenario project.');
}
const current = currentMicrotask(project);
if (!current || current.milestone.scenarioStage !== 'prep') {
return apiError('INVALID_REQUEST', 400, 'No active scenario prep stage to advance.');
}
const adv = advanceMicrotask(project, current.microtask.id, 'entered_scenario', {});
if (!adv.ok) {
return apiError('INVALID_REQUEST', 400, `Could not complete prep stage: ${adv.error}`);
}
// If the prep stage was the last milestone (no scene after it), there
// is no handover to consume — but that is an incoherent scenario the
// generator/validator already prevents. Consume the handover into the
// scene stage when present.
const cont = continueAfterHandover(project);
return apiSuccess({
project,
activatedMicrotaskId: cont.ok ? cont.activatedMicrotaskId : undefined,
});
}
// SCENARIO ONLY (act model). The learner clicked "finish this act" in the
// sidebar. Deterministically completes the ENTIRE active roleplay
// milestone (all its checkpoint beats at once) and stages the handover for
// the "next stage" button. No LLM, no per-beat judgement — checkpoint
// achievement is scored later by the final evaluator. Strictly gated to a
// scenario roleplay stage; rejected otherwise so ordinary projects and
// prep/wrapup are never affected.
case 'complete_act': {
if (!project.scenario) {
return apiError('INVALID_REQUEST', 400, 'Not a scenario project.');
}
const r = completeRoleplayAct(project, 'act_completed_by_learner');
if (!r.ok) {
return apiError('INVALID_REQUEST', 400, `Could not finish act: ${r.error}`);
}
return apiSuccess({ project });
}
default:
return apiError('INVALID_REQUEST', 400, `Unknown action: ${String(body.action)}`);
}
}