Files
openmaic/OpenMAIC/lib/pbl/v2/operations/runtime/submission.ts
2026-08-16 14:58:47 +08:00

163 lines
5.7 KiB
TypeScript

/**
* PBL v2 — Submission helpers.
*
* Submissions are pieces of learner-produced work (pasted text /
* uploaded file / link) attached to a microtask. The Instructor
* reads them when evaluating, and the evaluator includes them in
* the milestone / final eval prompts.
*
* v0 (PR 3): only the helper signatures and minimal append/list
* behavior. Upload handling lives in the workspace UI (PR 4) and
* paste-text handling lives in the workspace submission panel.
*/
import type { PBLProjectV2, PBLSubmission, PBLSubmissionKind } from '../../types';
import { trimmedPBLText } from '../../readers';
import { appendRuntimeEvent, mintRuntimeEventId } from '../kernel/runtime-events';
function newId(prefix: string): string {
return (
prefix + '_' + Math.random().toString(16).slice(2, 8) + Math.random().toString(16).slice(2, 8)
);
}
export function addSubmission(
project: PBLProjectV2,
args: {
microtaskId: string;
milestoneId?: string;
kind: PBLSubmissionKind;
content: string;
filename?: string;
mimeType?: string;
fileUrl?: string;
summary?: string;
},
): PBLSubmission {
const sub: PBLSubmission = {
id: newId('sub'),
microtaskId: args.microtaskId,
milestoneId: args.milestoneId,
kind: args.kind,
content: args.content,
filename: args.filename,
mimeType: args.mimeType,
fileUrl: args.fileUrl,
summary: args.summary,
createdAt: new Date().toISOString(),
};
project.submissions.push(sub);
appendRuntimeEvent(project, {
id: mintRuntimeEventId(),
kind: 'submission_created',
actorType: 'user',
submissionId: sub.id,
ts: sub.createdAt,
microtaskId: sub.microtaskId,
milestoneId: sub.milestoneId,
});
project.updatedAt = sub.createdAt;
return sub;
}
export function listSubmissionsForMicrotask(
project: PBLProjectV2,
microtaskId: string,
): PBLSubmission[] {
return project.submissions.filter((s) => s.microtaskId === microtaskId);
}
export function latestSubmissionForMicrotask(
project: PBLProjectV2,
microtaskId: string,
): PBLSubmission | undefined {
return listSubmissionsForMicrotask(project, microtaskId)
.slice()
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
}
/** Format only the latest submission for task-level evaluation.
*
* Revisions are common: the evaluator should grade the learner's current
* attempt, not re-score every earlier draft. Older attempts remain available
* through prior task evaluations and chat memory, but their raw content stays
* out of the current grading evidence so stale mistakes do not pollute the
* latest score. */
export function summarizeLatestSubmissionForMicrotask(
project: PBLProjectV2,
microtaskId: string,
maxChars = 4000,
): string {
const sub = latestSubmissionForMicrotask(project, microtaskId);
if (!sub) return '';
const headerBits: string[] = [`Latest submission (${sub.kind}`];
if (sub.filename) headerBits.push(`, ${sub.filename}`);
headerBits.push(`, ${sub.createdAt})`);
const header = `\n--- ${headerBits.join('')} ---\n`;
// Image submissions carry the picture as a multimodal message part (see the
// evaluator); their text `content` is just an optional caption. Tell the LLM
// to grade the attached image when there's no caption text.
const isImage = typeof sub.mimeType === 'string' && sub.mimeType.startsWith('image/');
const submissionText = trimmedPBLText(sub.content);
const content = submissionText
? submissionText
: isImage
? '(Image submission — grade the attached image.)'
: '';
const room = Math.max(0, maxChars - header.length);
const snippet = content.length > room ? content.slice(0, room) : content;
const truncated = content.length > snippet.length;
return header + snippet + (truncated ? '\n[...latest submission truncated]' : '');
}
/**
* Pack every submission for a microtask into a single text block,
* capped at ``maxChars`` so an enormous file upload can't blow the
* evaluator prompt over its token budget.
*
* Returns the empty string when no submissions exist — the caller
* (task evaluator) skips the section entirely in that case, which is
* how the D1-B branch (skip task eval when nothing was submitted)
* stays clean: even if the prompt builder is invoked with an empty
* task, the produced prompt simply has no submission section.
*
* Concatenation is plain text with a per-submission header so the
* LLM can tell submissions apart (kind, filename, timestamp). When
* the budget runs out we append a single ``[...older submissions
* truncated]`` marker so the LLM doesn't think the truncation is
* part of the learner's actual output.
*/
export function summarizeSubmissionsForMicrotask(
project: PBLProjectV2,
microtaskId: string,
maxChars = 4000,
): string {
const subs = listSubmissionsForMicrotask(project, microtaskId);
if (subs.length === 0) return '';
const parts: string[] = [];
let remaining = maxChars;
let truncated = false;
subs.forEach((s, idx) => {
if (truncated) return;
const headerBits: string[] = [`Submission ${idx + 1} (${s.kind}`];
if (s.filename) headerBits.push(`, ${s.filename}`);
headerBits.push(`, ${s.createdAt})`);
const header = `\n--- ${headerBits.join('')} ---\n`;
const content = s.content ?? '';
const room = Math.max(0, remaining - header.length);
if (room <= 0) {
truncated = true;
return;
}
const snippet = content.length > room ? content.slice(0, room) : content;
parts.push(header + snippet);
remaining -= header.length + snippet.length;
if (content.length > snippet.length) truncated = true;
if (remaining <= 0) truncated = true;
});
if (truncated) parts.push('\n[...older submissions truncated]');
return parts.join('');
}