chore: establish learning module baseline
This commit is contained in:
commit
2d04197f3f
2409 files changed
+533050
No files matched your search
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* PBL v2 — Apply one SSE event to a working project clone.
|
||||
*
|
||||
* Pure (no React, no fetch): given an incoming event and a project
|
||||
* clone, return the (possibly mutated) clone plus push the token
|
||||
* delta into the live draft state setter.
|
||||
*
|
||||
* Extracted from the hook to keep the streaming loop tight and to
|
||||
* allow direct unit testing of patch application logic.
|
||||
*/
|
||||
|
||||
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
|
||||
import type { PBLSSEEvent } from '@/lib/pbl/v2/api/sse';
|
||||
import { applyAdvanceProjectPatch } from '@/lib/pbl/v2/operations/runtime/advance-patch';
|
||||
import { capEngagementEvents } from '@/lib/pbl/v2/operations/kernel/engagement';
|
||||
import { PBL_SIMULATOR_AGENT_ID } from '@/lib/pbl/v2/operations/kernel/progress';
|
||||
import {
|
||||
appendProficiencyUpdatedRuntimeEvent,
|
||||
appendRuntimeEvent,
|
||||
milestoneIdForMicrotask,
|
||||
mintRuntimeEventId,
|
||||
} from '@/lib/pbl/v2/operations/kernel/runtime-events';
|
||||
import { isStandaloneDividerMessage, stripEmbeddedDividerMarkers } from './protocol-markers';
|
||||
import type { PBLChatMessage, PBLRuntimeActorType } from '@/lib/pbl/v2/types';
|
||||
|
||||
function cleanProtocolMarkersFromMessage<M extends { content: string }>(message: M): M {
|
||||
const trimmed = message.content.trimStart();
|
||||
if (isStandaloneDividerMessage(trimmed)) {
|
||||
return message;
|
||||
}
|
||||
const cleaned = stripEmbeddedDividerMarkers(message.content);
|
||||
if (cleaned === message.content) return message;
|
||||
return { ...message, content: cleaned };
|
||||
}
|
||||
|
||||
function actorTypeForMessage(message: PBLChatMessage): PBLRuntimeActorType {
|
||||
if (message.roleType === 'user') return 'user';
|
||||
if (message.roleType === 'system') return 'system';
|
||||
return 'agent';
|
||||
}
|
||||
|
||||
export function applyInstructorEvent(
|
||||
event: PBLSSEEvent,
|
||||
project: PBLProjectV2,
|
||||
setDraft: (fn: (prev: string) => string) => void,
|
||||
): PBLProjectV2 {
|
||||
switch (event.type) {
|
||||
case 'token':
|
||||
setDraft((prev) => prev + event.delta);
|
||||
return project;
|
||||
|
||||
case 'reset_draft':
|
||||
// An advancing turn discarded its streamed prose in favour of the
|
||||
// isolated wrap-up; drop the live draft so a leaked next-task preview
|
||||
// doesn't linger until the wrap-up message patch arrives.
|
||||
setDraft(() => '');
|
||||
return project;
|
||||
|
||||
case 'project_patch': {
|
||||
const next = structuredClone(project);
|
||||
const patch = event.patch;
|
||||
switch (patch.kind) {
|
||||
case 'message': {
|
||||
// SCENARIO ONLY routing: simulator-spoken lines and neutral
|
||||
// system narration land on the dedicated Simulator thread;
|
||||
// everything else (instructor / divider) stays on the
|
||||
// Instructor thread exactly as before. Ordinary projects only
|
||||
// ever produce instructor/user messages, so this is a no-op
|
||||
// for them (the simulator thread never exists).
|
||||
const toSimulator =
|
||||
patch.message.roleType === 'simulator' || patch.message.roleType === 'system';
|
||||
const thread = toSimulator
|
||||
? next.threads.find((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)
|
||||
: next.threads.find((t) => {
|
||||
const r = next.roles.find((r) => r.id === t.agentId);
|
||||
return r?.type === 'instructor';
|
||||
});
|
||||
const message = cleanProtocolMarkersFromMessage(patch.message);
|
||||
if (thread && !thread.messages.some((m) => m.id === message.id) && message.content) {
|
||||
thread.messages.push(message);
|
||||
const actorType = actorTypeForMessage(message);
|
||||
appendRuntimeEvent(next, {
|
||||
id: mintRuntimeEventId(),
|
||||
kind: 'message_created',
|
||||
actorType,
|
||||
actorRoleId: actorType === 'agent' ? message.agentId : undefined,
|
||||
messageId: message.id,
|
||||
threadId: thread.agentId,
|
||||
ts: message.ts,
|
||||
microtaskId: message.microtaskId,
|
||||
milestoneId: milestoneIdForMicrotask(next, message.microtaskId),
|
||||
});
|
||||
}
|
||||
// A message patch is the committed version of the live
|
||||
// assistant draft. Clear the draft immediately so the UI
|
||||
// never shows the same response as both a saved bubble and
|
||||
// an "typing" bubble while follow-up patches arrive.
|
||||
setDraft(() => '');
|
||||
break;
|
||||
}
|
||||
case 'engagement_event': {
|
||||
const event = patch.event ?? {
|
||||
id: 'evt_local_' + Date.now().toString(16) + Math.random().toString(16).slice(2, 6),
|
||||
kind: patch.eventKind as PBLProjectV2['engagementEvents'][number]['kind'],
|
||||
microtaskId: patch.microtaskId,
|
||||
milestoneId: patch.milestoneId,
|
||||
ts: patch.ts ?? new Date().toISOString(),
|
||||
payload: patch.payload,
|
||||
};
|
||||
if (!next.engagementEvents.some((existing) => existing.id === event.id)) {
|
||||
next.engagementEvents.push(event);
|
||||
capEngagementEvents(next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'advance': {
|
||||
applyAdvanceProjectPatch(next, patch);
|
||||
break;
|
||||
}
|
||||
case 'handover': {
|
||||
next.pendingHandover = patch.handover;
|
||||
appendRuntimeEvent(next, {
|
||||
id: mintRuntimeEventId(),
|
||||
kind: 'handover_staged',
|
||||
actorType: 'system',
|
||||
completedMilestoneId: patch.handover.completedMilestoneId,
|
||||
nextMilestoneId: patch.handover.nextMilestoneId,
|
||||
nextMicrotaskId: patch.handover.nextTaskId,
|
||||
ts: new Date().toISOString(),
|
||||
milestoneId: patch.handover.completedMilestoneId,
|
||||
microtaskId: patch.handover.nextTaskId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'evaluation': {
|
||||
next.evaluations.push(patch.evaluation);
|
||||
appendRuntimeEvent(next, {
|
||||
id: mintRuntimeEventId(),
|
||||
kind: 'evaluation_created',
|
||||
actorType: 'system',
|
||||
evaluationId: patch.evaluation.id,
|
||||
ts: patch.evaluation.createdAt,
|
||||
microtaskId: patch.evaluation.microtaskId,
|
||||
milestoneId:
|
||||
patch.evaluation.milestoneId ??
|
||||
milestoneIdForMicrotask(next, patch.evaluation.microtaskId),
|
||||
});
|
||||
// Evaluation patches are the committed version of the live
|
||||
// evaluator draft. Clear it here for the same reason we
|
||||
// clear message drafts above: the learner should not see a
|
||||
// streamed preview duplicated under the finished card.
|
||||
setDraft(() => '');
|
||||
break;
|
||||
}
|
||||
case 'proficiency': {
|
||||
// Adaptive engine state update. Replace the assessment
|
||||
// wholesale and mirror the tier onto the legacy
|
||||
// `proficiency` field so older consumers (tier-guidance
|
||||
// block, dev tooling) stay in sync. The chat does NOT
|
||||
// render this — it's only consumed by the dev badge.
|
||||
next.proficiencyAssessment = patch.assessment;
|
||||
next.proficiency = patch.assessment.tier;
|
||||
appendProficiencyUpdatedRuntimeEvent(next);
|
||||
break;
|
||||
}
|
||||
}
|
||||
next.updatedAt = new Date().toISOString();
|
||||
return next;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
case 'tool_call':
|
||||
case 'done':
|
||||
default:
|
||||
return project;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user