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

233 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { StatelessChatRequest } from '@/lib/types/chat';
import { buildWhiteboardConflicts } from './whiteboard-conflicts';
// ==================== Element Summarization ====================
/**
* Strip HTML tags to extract plain text
*/
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim();
}
/**
* Summarize a single PPT element into a one-line description
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes
function summarizeElement(el: any): string {
const id = el.id ? `[id:${el.id}]` : '';
const pos = `at (${Math.round(el.left)},${Math.round(el.top)})`;
const size =
el.width != null && el.height != null
? ` size ${Math.round(el.width)}×${Math.round(el.height)}`
: el.width != null
? ` w=${Math.round(el.width)}`
: '';
switch (el.type) {
case 'text': {
const text = stripHtml(el.content || '').slice(0, 60);
const suffix = text.length >= 60 ? '...' : '';
return `${id} text${el.textType ? `[${el.textType}]` : ''}: "${text}${suffix}" ${pos}${size}`;
}
case 'image': {
const src = el.src?.startsWith('data:') ? '[embedded]' : el.src?.slice(0, 50) || 'unknown';
return `${id} image: ${src} ${pos}${size}`;
}
case 'shape': {
const shapeText = el.text?.content ? stripHtml(el.text.content).slice(0, 40) : '';
return `${id} shape${shapeText ? `: "${shapeText}"` : ''} ${pos}${size}`;
}
case 'chart':
return `${id} chart[${el.chartType}]: labels=[${(el.data?.labels || []).slice(0, 4).join(',')}] ${pos}${size}`;
case 'table': {
const rows = el.data?.length || 0;
const cols = el.data?.[0]?.length || 0;
return `${id} table: ${rows}x${cols} ${pos}${size}`;
}
case 'latex':
return `${id} latex: "${(el.latex || '').slice(0, 40)}" ${pos}${size}`;
case 'line': {
const lx = Math.round(el.left ?? 0);
const ly = Math.round(el.top ?? 0);
const sx = el.start?.[0] ?? 0;
const sy = el.start?.[1] ?? 0;
const ex = el.end?.[0] ?? 0;
const ey = el.end?.[1] ?? 0;
return `${id} line: (${lx + sx},${ly + sy}) → (${lx + ex},${ly + ey})`;
}
case 'code': {
const lang = el.language || 'unknown';
const lineCount = el.lines?.length || 0;
const codeFn = el.fileName ? ` "${el.fileName}"` : '';
const linePreview = (el.lines || [])
.slice(0, 10)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((l: any) => ` ${l.id}: ${l.content}`)
.join('\n');
const moreLines = lineCount > 10 ? `\n ... and ${lineCount - 10} more lines` : '';
return `${id} code${codeFn} (${lang}, ${lineCount} lines) ${pos}${size}\n${linePreview}${moreLines}`;
}
case 'video':
return `${id} video ${pos}${size}`;
case 'audio':
return `${id} audio ${pos}${size}`;
default:
return `${id} ${el.type || 'unknown'} ${pos}${size}`;
}
}
/**
* Summarize an array of elements into line descriptions
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes
export function summarizeElements(elements: any[]): string {
if (elements.length === 0) return ' (empty)';
const lines = elements.map((el, i) => ` ${i + 1}. ${summarizeElement(el)}`);
return lines.join('\n');
}
// ==================== State Context ====================
/**
* Build context string from store state
*/
export function buildStateContext(storeState: StatelessChatRequest['storeState']): string {
const { stage, scenes, currentSceneId, mode, whiteboardOpen, quizResults } = storeState;
const lines: string[] = [];
// Mode
lines.push(`Mode: ${mode}`);
// Whiteboard status
lines.push(
`Whiteboard: ${whiteboardOpen ? 'OPEN (slide canvas is hidden)' : 'closed (slide canvas is visible)'}`,
);
// Stage info
if (stage) {
lines.push(
`Course: ${stage.name || 'Untitled'}${stage.description ? ` - ${stage.description}` : ''}`,
);
}
// Scenes summary
lines.push(`Total scenes: ${scenes.length}`);
if (currentSceneId) {
const currentScene = scenes.find((s) => s.id === currentSceneId);
if (currentScene) {
lines.push(
`Current scene: "${currentScene.title}" (${currentScene.type}, id: ${currentSceneId})`,
);
// Slide scene: include element details
if (currentScene.content.type === 'slide') {
const elements = currentScene.content.canvas.elements;
lines.push(`Current slide elements (${elements.length}):\n${summarizeElements(elements)}`);
}
// Quiz scene: include question summary, or post-submit results when the
// student has finished. Hydration of `quizResults` happens client-side in
// use-chat-sessions; absent here means the student has not submitted (or
// the active scene is not the quiz that owns the results).
if (currentScene.content.type === 'quiz') {
const questions = currentScene.content.questions;
const hasGradedResults =
!!quizResults && quizResults.sceneId === currentSceneId && quizResults.results.length > 0;
if (hasGradedResults && quizResults) {
// Student has submitted. Surface their answers, correctness, the
// grader's comment on short-answer questions, and the canonical
// analysis so the agent can give targeted feedback on the actual
// mistakes instead of re-teaching everything.
const resultsById = new Map(quizResults.results.map((r) => [r.questionId, r]));
const answersById = quizResults.answers;
const lineEntries = questions.map((q, i) => {
const r = resultsById.get(q.id);
const ans = answersById?.[q.id];
const studentAnswer = Array.isArray(ans) ? ans.join(', ') : (ans ?? '');
const correctAnswer =
Array.isArray(q.answer) && q.answer.length > 0 ? q.answer.join(', ') : '(open-ended)';
const verdict = r ? r.status.toUpperCase() : 'UNGRADED';
const points = q.points ?? 1;
const earned = r?.earned ?? 0;
const entry: string[] = [
` ${i + 1}. [${q.type}] ${q.question}`,
` Student answer: ${studentAnswer || '(empty)'}`,
` Correct answer: ${correctAnswer}`,
` Verdict: ${verdict} (${earned}/${points} pts)`,
];
if (q.analysis) entry.push(` Reference analysis: ${q.analysis}`);
if (r?.aiComment) entry.push(` AI grader comment: ${r.aiComment}`);
return entry.join('\n');
});
const score = quizResults.results.reduce((acc, r) => acc + (r.earned ?? 0), 0);
const total = questions.reduce((acc, q) => acc + (q.points ?? 1), 0);
lines.push(
`Quiz results — the student JUST submitted (${score}/${total} pts). Use these to address THIS student's specific mistakes; do not re-teach what they already got right. Walk through wrong answers, acknowledge correct ones briefly, and tie feedback back to the underlying concept.\n${lineEntries.join('\n')}`,
);
} else {
// Student has NOT submitted yet. Surface the questions in full so the
// agent CAN help when the student asks about a specific item — but
// strict rules below forbid using them proactively. The split matters:
// suppressing the text entirely makes the agent useless for clarifying
// questions; exposing it without rules let it recite the whole quiz
// the moment a user said "I'm done". We want both — visibility AND
// restraint.
const qSummary = questions
.map((q, i) => {
const optionsPart =
q.options && q.options.length > 0
? `\n Options: ${q.options.map((o) => `${o.value}. ${o.label}`).join(' | ')}`
: '';
return ` ${i + 1}. [${q.type}] ${q.question}${optionsPart}`;
})
.join('\n');
lines.push(
[
`Quiz scene — the student has NOT submitted yet. ${questions.length} question(s) below. You have this so you can clarify when the student asks about a specific item — NOT so you can teach them through it preemptively.`,
'Strict rules while the quiz is unsubmitted (override everything else):',
'- Do NOT proactively list, recite, paraphrase, summarise, or walk through the questions. Mentioning them at all on your own initiative is a leak.',
'- Do NOT reveal the correct answer, eliminate options, or hint strongly enough that the answer is obvious. Even a leading phrase like "think about whether x is really an integer" is too much when it points at the answer.',
'- Do NOT teach the underlying concept end-to-end here. The concepts were already taught earlier; re-teaching them now is equivalent to giving the answers.',
'- If the student claims to be done but no submitted results have arrived in this context, treat the absence of `Quiz results` above as authoritative — they have NOT submitted. Point them at the Submit button on the right-hand panel; do not start grading or summarising from memory.',
'- If the student asks for help on a SPECIFIC question (by number, by option, or by quoting the stem), you MAY ask a single Socratic question or clarify a concept WITHOUT naming the correct option. Otherwise stay encouraging and meta ("看不懂哪一题?" / "Take it one question at a time").',
'- You MAY answer how-the-quiz-works questions (how to submit, how many questions, what types) and offer encouragement.',
].join('\n') + `\n${qSummary}`,
);
}
}
}
} else if (scenes.length > 0) {
lines.push('No scene currently selected');
}
// List first few scenes
if (scenes.length > 0) {
const sceneSummary = scenes
.slice(0, 5)
.map((s, i) => ` ${i + 1}. ${s.title} (${s.type}, id: ${s.id})`)
.join('\n');
lines.push(
`Scenes:\n${sceneSummary}${scenes.length > 5 ? `\n ... and ${scenes.length - 5} more` : ''}`,
);
}
// Whiteboard content (last whiteboard in the stage)
if (stage?.whiteboard && stage.whiteboard.length > 0) {
const lastWb = stage.whiteboard[stage.whiteboard.length - 1];
const wbElements = lastWb.elements || [];
lines.push(
`Whiteboard (last of ${stage.whiteboard.length}, ${wbElements.length} elements):\n${summarizeElements(wbElements)}`,
);
const conflictsText = buildWhiteboardConflicts(wbElements);
if (conflictsText) lines.push(conflictsText);
}
return lines.join('\n');
}