Files
BoostAI/FineTune/public/app.js
2026-05-26 13:43:09 +01:00

1845 lines
61 KiB
JavaScript

const STORAGE_KEY = "boostai-finetune-helper-state-v3";
const DATASET_STORAGE_KEY = "boostai-finetune-helper-dataset-v2";
const DRAFT_STORAGE_KEY = "boostai-finetune-helper-drafts-v1";
const ACTIVE_DRAFT_STORAGE_KEY = "boostai-finetune-helper-active-draft-v1";
const TRAIN_VAL_SPLIT = 0.2;
const fixedFieldIds = [
"assignmentId",
"studentId",
"assignmentTitle",
"instructions",
"passThreshold",
"topic",
"difficulty",
"questionCount",
"generatorSeed",
"assignmentSummary",
"recommendedNextStep",
];
const fixedElements = Object.fromEntries(fixedFieldIds.map((id) => [id, document.getElementById(id)]));
const questionsContainer = document.getElementById("questions-container");
const questionSummary = document.getElementById("question-summary");
const aiConfigStatus = document.getElementById("ai-config-status");
const questionGeneratorStatus = document.getElementById("question-generator-status");
const collabStatus = document.getElementById("collab-status");
const presenceStatus = document.getElementById("presence-status");
const saveStatus = document.getElementById("save-status");
const recordPreview = document.getElementById("recordPreview");
const trainingPreview = document.getElementById("trainingPreview");
const datasetStatus = document.getElementById("datasetStatus");
const datasetEmpty = document.getElementById("datasetEmpty");
const datasetList = document.getElementById("datasetList");
const draftStatus = document.getElementById("draft-status");
const draftSearch = document.getElementById("draft-search");
const draftSort = document.getElementById("draft-sort");
const draftList = document.getElementById("draft-list");
const toast = document.getElementById("toast");
const generateAssignmentButton = document.getElementById("generate-assignment");
const addQuestionButton = document.getElementById("add-question");
const generateStudentsButton = document.getElementById("generate-students");
const generateTeacherButton = document.getElementById("generate-teacher");
const saveExampleButton = document.getElementById("save-example");
const exportDatasetButton = document.getElementById("export-dataset");
const exportSplitButton = document.getElementById("export-split");
const filterAllButton = document.getElementById("filter-all");
const filterAttentionButton = document.getElementById("filter-attention");
const filterUnlabeledButton = document.getElementById("filter-unlabeled");
const expandAllQuestionsButton = document.getElementById("expand-all-questions");
const collapseAllQuestionsButton = document.getElementById("collapse-all-questions");
const newDraftButton = document.getElementById("new-draft");
const duplicateDraftButton = document.getElementById("duplicate-draft");
const renameDraftButton = document.getElementById("rename-draft");
const deleteDraftButton = document.getElementById("delete-draft");
const DEFAULT_WORKSPACE_ID = "shared";
let state = createSampleState();
let savedExamples = [];
let activeExampleId = null;
let drafts = [];
let activeDraftId = null;
let toastTimeout = null;
let uiState = {
questionFilter: "all",
collapsedQuestions: new Set(),
draftFilter: "",
draftSort: "updated",
};
let collaboration = {
clientId: null,
ws: null,
workspaceId: resolveWorkspaceId(),
connected: false,
ready: false,
presenceCount: 1,
serverVersion: 0,
reconnectTimer: null,
publishTimer: null,
applyingRemoteState: false,
lastSentStateHash: "",
};
initialize();
async function initialize() {
bindEvents();
hydrateDrafts();
hydrateDataset();
renderAll();
renderCollaborationStatus();
connectCollaboration();
await loadConfig();
}
function bindEvents() {
for (const [field, element] of Object.entries(fixedElements)) {
element.addEventListener("input", () => {
state[field] = element.value;
persistState();
renderPreviews();
if (field === "questionCount") renderQuestionSummary();
});
}
draftSearch.addEventListener("input", () => {
uiState.draftFilter = draftSearch.value.trim().toLowerCase();
renderDrafts();
});
draftSort.addEventListener("change", () => {
uiState.draftSort = draftSort.value;
renderDrafts();
});
document.getElementById("load-sample").addEventListener("click", () => {
activeExampleId = null;
state = createSampleState();
renderAll();
showToast("Loaded sample assignment.", "success");
});
document.getElementById("clear-form").addEventListener("click", () => {
activeExampleId = null;
state = createEmptyState();
renderAll();
showToast("Cleared assignment workspace.", "success");
});
newDraftButton.addEventListener("click", () => {
createDraftFromCurrentState({
state: createEmptyState(),
label: "New assignment draft",
successMessage: "Created a fresh assignment draft.",
});
});
duplicateDraftButton.addEventListener("click", () => {
createDraftFromCurrentState({
state,
label: `${getActiveDraftTitle()} copy`,
successMessage: "Duplicated the current assignment draft.",
});
});
renameDraftButton.addEventListener("click", () => {
const current = getActiveDraft();
if (!current) return;
const nextLabel = window.prompt("Rename the current assignment draft", current.label || getDraftTitle(current));
if (nextLabel === null) return;
current.label = nextLabel.trim();
persistDrafts();
renderDrafts();
showToast("Updated draft name.", "success");
});
deleteDraftButton.addEventListener("click", () => {
deleteActiveDraft();
});
addQuestionButton.addEventListener("click", () => {
state.questions.push(createBlankQuestion(state.questions.length + 1));
state.questionCount = String(state.questions.length);
expandQuestionAtIndex(state.questions.length - 1);
renderAll();
showToast("Added blank question.", "success");
});
filterAllButton.addEventListener("click", () => {
uiState.questionFilter = "all";
renderQuestions();
renderQuestionSummary();
});
filterAttentionButton.addEventListener("click", () => {
uiState.questionFilter = "attention";
renderQuestions();
renderQuestionSummary();
});
filterUnlabeledButton.addEventListener("click", () => {
uiState.questionFilter = "unlabeled";
renderQuestions();
renderQuestionSummary();
});
expandAllQuestionsButton.addEventListener("click", () => {
uiState.collapsedQuestions.clear();
renderQuestions();
});
collapseAllQuestionsButton.addEventListener("click", () => {
uiState.collapsedQuestions = new Set(state.questions.map((question, index) => getQuestionUiKey(question, index)));
renderQuestions();
});
generateAssignmentButton.addEventListener("click", async () => {
await runAssignmentGeneration();
});
generateStudentsButton.addEventListener("click", async () => {
await runAssignmentDraft({
button: generateStudentsButton,
path: "/api/assignment/student-draft",
successMessage: "Student submission drafted.",
apply(result) {
const byId = new Map((result.questions || []).map((question) => [String(question.questionId), question]));
state.questions = state.questions.map((question) => {
const drafted = byId.get(question.questionId) || byId.get(String(question.questionId));
if (!drafted) return question;
return {
...question,
studentAnswer: drafted.answerText || "",
workingSteps: drafted.workingSteps || "",
solveMode: drafted.solveMode || question.solveMode || "show_work",
};
});
},
});
});
generateTeacherButton.addEventListener("click", async () => {
await runAssignmentDraft({
button: generateTeacherButton,
path: "/api/assignment/teacher-draft",
successMessage: "Teacher review package drafted.",
apply(result) {
const byId = new Map((result.questions || []).map((question) => [String(question.questionId), question]));
state.questions = state.questions.map((question) => {
const drafted = byId.get(question.questionId) || byId.get(String(question.questionId));
if (!drafted) return question;
return {
...question,
aiFeedback: drafted.aiFeedback || "",
understandingScore: formatScore(drafted.understandingScore),
confidence: formatScore(drafted.confidence),
needsAttention: formatBoolean(drafted.needsAttention),
issueReason: drafted.issueReason || "",
};
});
state.assignmentSummary = result.assignmentSummary || "";
state.recommendedNextStep = result.recommendedNextStep || "";
},
});
});
saveExampleButton.addEventListener("click", () => {
try {
saveCurrentExample();
} catch (error) {
showToast(error instanceof Error ? error.message : "Could not save example.", "error");
}
});
document.getElementById("copy-record").addEventListener("click", async () => {
await copyPreview(recordPreview.textContent, "Labeled record copied.");
});
document.getElementById("copy-training").addEventListener("click", async () => {
await copyPreview(trainingPreview.textContent, "Fine-tune example copied.");
});
exportDatasetButton.addEventListener("click", () => {
try {
exportDatasetJsonl();
} catch (error) {
showToast(error instanceof Error ? error.message : "Export failed.", "error");
}
});
exportSplitButton.addEventListener("click", () => {
try {
exportTrainValidationSplit();
} catch (error) {
showToast(error instanceof Error ? error.message : "Split export failed.", "error");
}
});
questionsContainer.addEventListener("input", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const index = Number(target.dataset.questionIndex);
const field = target.dataset.questionField;
if (!Number.isInteger(index) || !field || !state.questions[index]) return;
state.questions[index][field] = target.value;
persistState();
renderPreviews();
renderQuestionSummary();
});
questionsContainer.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const actionTarget = target.closest("[data-action]");
if (!(actionTarget instanceof HTMLElement)) return;
const action = actionTarget.dataset.action;
const index = Number(actionTarget.dataset.questionIndex);
if (!Number.isInteger(index) || !state.questions[index]) return;
if (action === "remove-question") {
uiState.collapsedQuestions.delete(getQuestionUiKey(state.questions[index], index));
state.questions.splice(index, 1);
reindexQuestions();
renderAll();
showToast("Removed question.", "success");
return;
}
if (action === "duplicate-question") {
const duplicate = normalizeQuestion(state.questions[index], index + 1);
duplicate.position = index + 2;
state.questions.splice(index + 1, 0, duplicate);
reindexQuestions();
expandQuestionAtIndex(index + 1);
renderAll();
showToast("Duplicated question.", "success");
return;
}
if (action === "move-question-up" && index > 0) {
[state.questions[index - 1], state.questions[index]] = [state.questions[index], state.questions[index - 1]];
reindexQuestions();
renderAll();
showToast("Moved question up.", "success");
return;
}
if (action === "move-question-down" && index < state.questions.length - 1) {
[state.questions[index], state.questions[index + 1]] = [state.questions[index + 1], state.questions[index]];
reindexQuestions();
renderAll();
showToast("Moved question down.", "success");
return;
}
if (action === "toggle-question") {
toggleQuestionCollapsed(index);
renderQuestions();
}
});
draftList.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const actionTarget = target.closest("[data-draft-action]");
if (!(actionTarget instanceof HTMLElement)) return;
const action = actionTarget.dataset.draftAction;
const draftId = actionTarget.dataset.draftId;
if (!draftId) return;
if (action === "open-draft") {
loadDraft(draftId);
}
});
}
function hydrateDrafts() {
const rawDrafts = localStorage.getItem(DRAFT_STORAGE_KEY);
const legacyState = localStorage.getItem(STORAGE_KEY);
if (rawDrafts) {
try {
const parsed = JSON.parse(rawDrafts);
drafts = Array.isArray(parsed) ? parsed.map(normalizeDraft).filter(Boolean) : [];
} catch {
drafts = [];
}
}
if (!drafts.length && legacyState) {
try {
drafts = [createDraftRecord(normalizeState(JSON.parse(legacyState)), { id: crypto.randomUUID() })];
} catch {
drafts = [];
}
}
if (!drafts.length) {
drafts = [createDraftRecord(createSampleState(), { id: crypto.randomUUID(), label: "Sample assignment" })];
}
const storedActiveDraftId = localStorage.getItem(ACTIVE_DRAFT_STORAGE_KEY);
activeDraftId = drafts.some((draft) => draft.id === storedActiveDraftId) ? storedActiveDraftId : drafts[0].id;
state = normalizeState(getActiveDraft()?.state || createSampleState());
}
function hydrateDataset() {
const raw = localStorage.getItem(DATASET_STORAGE_KEY);
if (!raw) {
savedExamples = [];
return;
}
try {
const parsed = JSON.parse(raw);
savedExamples = Array.isArray(parsed) ? parsed.filter(isSavedExampleShape) : [];
} catch {
savedExamples = [];
}
}
function renderAll() {
renderFixedFields();
draftSearch.value = uiState.draftFilter;
draftSort.value = uiState.draftSort;
renderQuestions();
renderQuestionSummary();
renderQuestionToolbar();
persistState();
renderPreviews();
renderDrafts();
renderDataset();
}
function renderFixedFields() {
for (const [field, element] of Object.entries(fixedElements)) {
element.value = state[field] || "";
}
}
function renderQuestions() {
questionsContainer.innerHTML = "";
const visibleQuestions = state.questions.filter((question, index) => matchesQuestionFilter(question, index));
if (!visibleQuestions.length) {
const empty = document.createElement("div");
empty.className = "empty-state question-empty-state";
empty.textContent =
uiState.questionFilter === "all"
? "No questions yet. Generate an assignment or add a blank question."
: "No questions match the current filter.";
questionsContainer.appendChild(empty);
return;
}
for (const question of visibleQuestions) {
const index = Math.max(0, (question.position || 1) - 1);
const collapsed = isQuestionCollapsed(index);
const statusPills = buildQuestionStatusPills(question);
const article = document.createElement("article");
article.className = `question-card${collapsed ? " is-collapsed" : ""}`;
article.innerHTML = `
<div class="question-card-header">
<div class="question-card-heading">
<div class="question-heading-topline">
<h3>Question ${question.position || index + 1}</h3>
<button type="button" class="button-secondary button-compact" data-action="toggle-question" data-question-index="${index}">${collapsed ? "Expand" : "Collapse"}</button>
</div>
<p>${escapeHtml(question.title || summarizeText(question.prompt, 100) || "Untitled question")}</p>
<div class="question-status-row">${statusPills}</div>
</div>
<div class="hero-actions question-card-actions">
<span class="dataset-pill">ID ${escapeHtml(question.questionId || "?")}</span>
<button type="button" class="button-secondary button-compact" data-action="move-question-up" data-question-index="${index}" ${index === 0 ? "disabled" : ""}>Up</button>
<button type="button" class="button-secondary button-compact" data-action="move-question-down" data-question-index="${index}" ${index === state.questions.length - 1 ? "disabled" : ""}>Down</button>
<button type="button" class="button-secondary button-compact" data-action="duplicate-question" data-question-index="${index}">Duplicate</button>
<button type="button" class="button-danger" data-action="remove-question" data-question-index="${index}">Remove</button>
</div>
</div>
<div class="question-card-body" ${collapsed ? "hidden" : ""}>
<div class="form-grid three-up">
<label>
<span>Question ID</span>
<input type="number" min="1" step="1" value="${escapeAttribute(question.questionId)}" data-question-index="${index}" data-question-field="questionId" />
</label>
<label>
<span>Title</span>
<input type="text" value="${escapeAttribute(question.title)}" data-question-index="${index}" data-question-field="title" />
</label>
<label>
<span>Subject</span>
<input type="text" value="${escapeAttribute(question.subject)}" data-question-index="${index}" data-question-field="subject" />
</label>
</div>
<div class="form-grid three-up">
<label>
<span>Source</span>
<input type="text" value="${escapeAttribute(question.source)}" data-question-index="${index}" data-question-field="source" />
</label>
<label>
<span>Difficulty</span>
<input type="text" value="${escapeAttribute(question.difficulty)}" data-question-index="${index}" data-question-field="difficulty" />
</label>
<label>
<span>Solve mode</span>
<select data-question-index="${index}" data-question-field="solveMode">
${renderSelectOptions(["show_work", "mental_math", "calculator", "unknown"], question.solveMode || "show_work")}
</select>
</label>
</div>
<label>
<span>Tags</span>
<input type="text" value="${escapeAttribute(question.tags)}" placeholder="fractions, easy, rng_generated" data-question-index="${index}" data-question-field="tags" />
</label>
<label>
<span>Prompt</span>
<textarea rows="4" data-question-index="${index}" data-question-field="prompt">${escapeHtml(question.prompt)}</textarea>
</label>
<div class="form-grid two-up">
<label>
<span>Correct answer</span>
<textarea rows="5" data-question-index="${index}" data-question-field="correctAnswer">${escapeHtml(question.correctAnswer)}</textarea>
</label>
<label>
<span>Worked solution</span>
<textarea rows="5" data-question-index="${index}" data-question-field="workedSolution">${escapeHtml(question.workedSolution)}</textarea>
</label>
</div>
<div class="subpanel">
<h4>Student submission</h4>
<div class="form-grid two-up">
<label>
<span>Answer text</span>
<textarea rows="5" data-question-index="${index}" data-question-field="studentAnswer">${escapeHtml(question.studentAnswer)}</textarea>
</label>
<label>
<span>Working steps</span>
<textarea rows="5" data-question-index="${index}" data-question-field="workingSteps">${escapeHtml(question.workingSteps)}</textarea>
</label>
</div>
</div>
<div class="subpanel">
<h4>Teacher review</h4>
<label>
<span>AI feedback</span>
<textarea rows="5" data-question-index="${index}" data-question-field="aiFeedback">${escapeHtml(question.aiFeedback)}</textarea>
</label>
<div class="form-grid three-up">
<label>
<span>Understanding score</span>
<input type="number" min="0" max="1" step="0.01" value="${escapeAttribute(question.understandingScore)}" data-question-index="${index}" data-question-field="understandingScore" />
</label>
<label>
<span>Confidence</span>
<input type="number" min="0" max="1" step="0.01" value="${escapeAttribute(question.confidence)}" data-question-index="${index}" data-question-field="confidence" />
</label>
<label>
<span>Needs attention</span>
<select data-question-index="${index}" data-question-field="needsAttention">
<option value="" ${question.needsAttention ? "" : "selected"}>Select…</option>
<option value="true" ${question.needsAttention === "true" ? "selected" : ""}>true</option>
<option value="false" ${question.needsAttention === "false" ? "selected" : ""}>false</option>
</select>
</label>
</div>
<label>
<span>Issue reason</span>
<textarea rows="4" data-question-index="${index}" data-question-field="issueReason">${escapeHtml(question.issueReason)}</textarea>
</label>
</div>
</div>
`;
questionsContainer.appendChild(article);
}
}
function renderQuestionSummary() {
const total = state.questions.length;
const reviewed = state.questions.filter((question) => question.aiFeedback && question.issueReason).length;
const studentDrafted = state.questions.filter((question) => question.studentAnswer || question.workingSteps).length;
const needsAttention = state.questions.filter((question) => parseBoolean(question.needsAttention) === true).length;
const visible = state.questions.filter((question, index) => matchesQuestionFilter(question, index)).length;
questionSummary.textContent = total
? `${total} question${total === 1 ? "" : "s"}${studentDrafted} with student work • ${reviewed} labeled • ${needsAttention} need attention${visible !== total ? ` • showing ${visible}` : ""}`
: "No questions yet.";
}
function renderQuestionToolbar() {
const filters = {
all: filterAllButton,
attention: filterAttentionButton,
unlabeled: filterUnlabeledButton,
};
for (const [mode, button] of Object.entries(filters)) {
button.classList.toggle("is-active", uiState.questionFilter === mode);
}
}
function persistState() {
syncCurrentStateIntoActiveDraft();
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
persistDrafts();
saveStatus.textContent = `Autosaved draft at ${new Date().toLocaleTimeString()}`;
if (!collaboration.applyingRemoteState) {
scheduleWorkspacePublish();
}
}
function persistDrafts() {
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(drafts));
if (activeDraftId) {
localStorage.setItem(ACTIVE_DRAFT_STORAGE_KEY, activeDraftId);
}
}
function persistDataset() {
localStorage.setItem(DATASET_STORAGE_KEY, JSON.stringify(savedExamples));
}
function renderPreviews() {
const derived = buildDerivedExample(state);
recordPreview.textContent = JSON.stringify(derived.record, null, 2);
trainingPreview.textContent = JSON.stringify(derived.trainingExample, null, 2);
updateSaveButtonLabel();
}
function renderDataset() {
const count = savedExamples.length;
datasetStatus.textContent =
count === 0
? "No saved examples yet."
: `${count} saved assignment example${count === 1 ? "" : "s"} ready for export.`;
datasetEmpty.hidden = count > 0;
datasetList.innerHTML = "";
for (const example of savedExamples) {
const article = document.createElement("article");
article.className = `dataset-item${example.id === activeExampleId ? " active" : ""}`;
const header = document.createElement("div");
header.className = "dataset-item-header";
const summary = document.createElement("div");
const title = document.createElement("h3");
title.className = "dataset-item-title";
title.textContent = example.title;
summary.appendChild(title);
const meta = document.createElement("p");
meta.className = "dataset-item-meta";
meta.textContent = [
example.assignmentId || "No assignment ID",
example.studentId || "No student ID",
example.savedAt ? `Saved ${new Date(example.savedAt).toLocaleString()}` : null,
]
.filter(Boolean)
.join(" • ");
summary.appendChild(meta);
const actions = document.createElement("div");
actions.className = "dataset-item-actions";
const loadButton = document.createElement("button");
loadButton.type = "button";
loadButton.className = "button-secondary";
loadButton.textContent = example.id === activeExampleId ? "Loaded" : "Load";
loadButton.addEventListener("click", () => loadSavedExample(example.id));
actions.appendChild(loadButton);
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "button-danger";
deleteButton.textContent = "Delete";
deleteButton.addEventListener("click", () => deleteSavedExample(example.id));
actions.appendChild(deleteButton);
header.append(summary, actions);
article.appendChild(header);
const pills = document.createElement("div");
pills.className = "dataset-item-tags";
for (const label of buildDatasetPills(example)) {
const pill = document.createElement("span");
pill.className = "dataset-pill";
pill.textContent = label;
pills.appendChild(pill);
}
article.appendChild(pills);
datasetList.appendChild(article);
}
updateSaveButtonLabel();
}
function renderDrafts() {
const visibleDrafts = drafts.filter(matchesDraftFilter).sort(compareDraftsForList);
const count = drafts.length;
const activeTitle = getActiveDraftTitle();
draftStatus.textContent = `${count} local assignment${count === 1 ? "" : "s"}${activeTitle ? ` • Editing ${activeTitle}` : ""}`;
draftList.innerHTML = "";
if (!visibleDrafts.length) {
const empty = document.createElement("div");
empty.className = "empty-state draft-empty-state";
empty.textContent = drafts.length ? "No drafts match this search." : "Create a new assignment draft to start working on more than one assignment.";
draftList.appendChild(empty);
return;
}
for (const draft of visibleDrafts) {
const article = document.createElement("article");
article.className = `draft-item${draft.id === activeDraftId ? " active" : ""}`;
article.setAttribute("role", "option");
article.setAttribute("aria-selected", draft.id === activeDraftId ? "true" : "false");
article.innerHTML = `
<div class="draft-item-main" data-draft-action="open-draft" data-draft-id="${escapeAttribute(draft.id)}">
<div class="draft-item-row">
<h3 class="draft-item-title">${escapeHtml(getDraftTitle(draft))}</h3>
<p class="draft-item-updated">${escapeHtml(formatDraftUpdatedAt(draft.updatedAt))}</p>
</div>
<p class="draft-item-meta">${escapeHtml(buildDraftMeta(draft))}</p>
</div>
<div class="draft-item-actions">
<button type="button" class="button-secondary button-compact" data-draft-action="open-draft" data-draft-id="${escapeAttribute(draft.id)}">${draft.id === activeDraftId ? "Editing" : "Open"}</button>
</div>
`;
const pills = document.createElement("div");
pills.className = "dataset-item-tags";
for (const label of buildDraftPills(draft)) {
const pill = document.createElement("span");
pill.className = "dataset-pill";
pill.textContent = label;
pills.appendChild(pill);
}
article.appendChild(pills);
draftList.appendChild(article);
}
}
function updateSaveButtonLabel() {
saveExampleButton.textContent = activeExampleId ? "Update saved assignment example" : "Save assignment example";
}
function getActiveDraft() {
return drafts.find((draft) => draft.id === activeDraftId) || null;
}
function getActiveDraftTitle() {
return getDraftTitle(getActiveDraft());
}
function getDraftTitle(draft) {
if (!draft) return "Untitled assignment";
if (draft.label) return draft.label;
if (draft.state?.assignmentTitle) return draft.state.assignmentTitle;
if (draft.assignmentId) return draft.assignmentId;
if (draft.topic) return `${draft.topic} draft`;
return "Untitled assignment";
}
function buildDraftMeta(draft) {
return [
draft.assignmentId || "No assignment ID",
draft.studentId || "No student ID",
draft.topic ? `Topic ${draft.topic}` : null,
draft.questionCount ? `${draft.questionCount} question${draft.questionCount === 1 ? "" : "s"}` : null,
]
.filter(Boolean)
.join(" • ");
}
function buildDraftPills(draft) {
const pills = [];
if (draft.topic) pills.push(`topic ${draft.topic}`);
if (draft.difficulty) pills.push(draft.difficulty);
if (draft.questionCount) pills.push(`${draft.questionCount} questions`);
if (draft.id === activeDraftId) pills.push("active editor");
return pills;
}
function matchesDraftFilter(draft) {
if (!uiState.draftFilter) return true;
const haystack = [
getDraftTitle(draft),
draft.assignmentId,
draft.studentId,
draft.topic,
draft.difficulty,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(uiState.draftFilter);
}
function compareDraftsForList(left, right) {
const mode = uiState.draftSort || "updated";
if (mode === "title") {
return getDraftTitle(left).localeCompare(getDraftTitle(right));
}
if (mode === "topic") {
const topicCompare = (left.topic || "zzzz").localeCompare(right.topic || "zzzz");
if (topicCompare !== 0) return topicCompare;
return getDraftTitle(left).localeCompare(getDraftTitle(right));
}
if (mode === "questions") {
const questionCompare = (right.questionCount || 0) - (left.questionCount || 0);
if (questionCompare !== 0) return questionCompare;
return getDraftTitle(left).localeCompare(getDraftTitle(right));
}
return (Date.parse(right.updatedAt || 0) || 0) - (Date.parse(left.updatedAt || 0) || 0);
}
function formatDraftUpdatedAt(value) {
if (!value) return "No edits yet";
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return "Updated recently";
const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000));
if (diffMinutes < 1) return "Updated just now";
if (diffMinutes < 60) return `Updated ${diffMinutes}m ago`;
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) return `Updated ${diffHours}h ago`;
const diffDays = Math.round(diffHours / 24);
if (diffDays < 7) return `Updated ${diffDays}d ago`;
return `Updated ${new Date(timestamp).toLocaleDateString()}`;
}
function normalizeDraft(raw) {
if (!raw || typeof raw !== "object") return null;
const normalizedState = normalizeState(raw.state || raw);
return createDraftRecord(normalizedState, {
id: typeof raw.id === "string" && raw.id ? raw.id : crypto.randomUUID(),
label: typeof raw.label === "string" ? raw.label.trim() : "",
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : null,
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null,
});
}
function createDraftRecord(nextState, overrides = {}) {
const normalizedState = normalizeState(nextState);
const now = new Date().toISOString();
return {
id: overrides.id || crypto.randomUUID(),
label: typeof overrides.label === "string" ? overrides.label.trim() : "",
createdAt: overrides.createdAt || now,
updatedAt: overrides.updatedAt || now,
assignmentId: normalizedState.assignmentId,
studentId: normalizedState.studentId,
topic: normalizedState.topic,
difficulty: normalizedState.difficulty,
questionCount: normalizedState.questions.length,
state: normalizedState,
};
}
function syncCurrentStateIntoActiveDraft() {
const current = getActiveDraft();
if (!current) return;
const normalizedState = normalizeState(state);
const nextRecord = createDraftRecord(normalizedState, {
id: current.id,
label: current.label,
createdAt: current.createdAt,
});
const index = drafts.findIndex((draft) => draft.id === current.id);
if (index >= 0) {
drafts.splice(index, 1, nextRecord);
}
}
function createDraftFromCurrentState({ state: nextState, label = "", successMessage }) {
const draft = createDraftRecord(nextState, { label });
drafts.unshift(draft);
activeDraftId = draft.id;
activeExampleId = null;
state = normalizeState(draft.state);
primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
renderAll();
if (successMessage) showToast(successMessage, "success");
}
function loadDraft(id) {
const draft = drafts.find((item) => item.id === id);
if (!draft) {
showToast("Draft not found.", "error");
return;
}
activeDraftId = id;
activeExampleId = null;
state = normalizeState(draft.state || createEmptyState());
primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
renderAll();
showToast("Loaded assignment draft into the editor.", "success");
}
function deleteActiveDraft() {
if (!activeDraftId) return;
const current = getActiveDraft();
if (!current) return;
if (drafts.length === 1) {
drafts = [createDraftRecord(createEmptyState(), { label: "New assignment draft" })];
activeDraftId = drafts[0].id;
state = normalizeState(drafts[0].state);
activeExampleId = null;
primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
renderAll();
showToast("Reset the final remaining draft to a blank assignment.", "success");
return;
}
const currentIndex = drafts.findIndex((draft) => draft.id === activeDraftId);
drafts = drafts.filter((draft) => draft.id !== activeDraftId);
const fallback = drafts[Math.max(0, Math.min(currentIndex, drafts.length - 1))];
activeDraftId = fallback.id;
state = normalizeState(fallback.state);
activeExampleId = null;
primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
renderAll();
showToast(`Deleted “${getDraftTitle(current)}”.`, "success");
}
async function loadConfig() {
try {
const response = await fetch("/api/config");
const data = await response.json();
aiConfigStatus.textContent = data.hasAiConfig
? `Connected to ${data.model} via ${data.endpoint}`
: "Missing local AI env config";
questionGeneratorStatus.textContent = data.hasQuestionGeneratorConfig
? `Ready via ${data.backendUrl}`
: "Missing backend generator env config";
} catch {
aiConfigStatus.textContent = "Could not load local config";
questionGeneratorStatus.textContent = "Could not load local config";
}
}
function connectCollaboration() {
clearTimeout(collaboration.reconnectTimer);
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = new URL(`${protocol}//${window.location.host}/ws`);
url.searchParams.set("workspace", collaboration.workspaceId);
collaboration.connected = false;
collaboration.ready = false;
renderCollaborationStatus();
const ws = new WebSocket(url);
collaboration.ws = ws;
ws.addEventListener("open", () => {
renderCollaborationStatus();
});
ws.addEventListener("message", (event) => {
handleCollaborationMessage(event.data);
});
ws.addEventListener("close", () => {
if (collaboration.ws === ws) {
collaboration.connected = false;
collaboration.ready = false;
renderCollaborationStatus();
collaboration.reconnectTimer = setTimeout(() => {
connectCollaboration();
}, 1500);
}
});
ws.addEventListener("error", () => {});
}
function handleCollaborationMessage(raw) {
let message;
try {
message = JSON.parse(raw);
} catch {
return;
}
if (message.type === "workspace:init") {
collaboration.clientId = message.clientId || null;
collaboration.connected = true;
collaboration.ready = true;
collaboration.serverVersion = Number(message.version || 0);
collaboration.presenceCount = Number(message.presenceCount || 1);
renderCollaborationStatus();
if (message.state && typeof message.state === "object") {
applyRemoteWorkspaceState(message.state);
} else {
publishWorkspaceNow({ force: true });
}
return;
}
if (message.type === "workspace:presence") {
collaboration.connected = true;
collaboration.presenceCount = Number(message.presenceCount || 1);
renderCollaborationStatus();
return;
}
if (message.type === "workspace:snapshot") {
collaboration.connected = true;
collaboration.ready = true;
collaboration.serverVersion = Number(message.version || collaboration.serverVersion || 0);
collaboration.presenceCount = Number(message.presenceCount || collaboration.presenceCount || 1);
renderCollaborationStatus();
if (message.actorClientId === collaboration.clientId) {
return;
}
if (message.state && typeof message.state === "object") {
applyRemoteWorkspaceState(message.state);
}
return;
}
if (message.type === "workspace:error" && message.message) {
showToast(message.message, "error");
}
}
function applyRemoteWorkspaceState(nextState) {
const normalized = normalizeState(nextState);
const nextHash = JSON.stringify(normalized);
const currentHash = JSON.stringify(normalizeState(state));
collaboration.lastSentStateHash = nextHash;
if (nextHash === currentHash) {
return;
}
collaboration.applyingRemoteState = true;
activeExampleId = null;
state = normalized;
renderAll();
collaboration.applyingRemoteState = false;
saveStatus.textContent = `Synced shared workspace at ${new Date().toLocaleTimeString()}`;
}
function scheduleWorkspacePublish() {
if (!collaboration.ready) return;
clearTimeout(collaboration.publishTimer);
collaboration.publishTimer = setTimeout(() => {
publishWorkspaceNow();
}, 150);
}
function publishWorkspaceNow(options = {}) {
if (!collaboration.ready) return false;
if (!collaboration.ws || collaboration.ws.readyState !== WebSocket.OPEN) return false;
const normalized = normalizeState(state);
const stateHash = JSON.stringify(normalized);
if (!options.force && stateHash === collaboration.lastSentStateHash) {
return false;
}
collaboration.lastSentStateHash = stateHash;
collaboration.ws.send(
JSON.stringify({
type: "workspace:update",
workspaceId: collaboration.workspaceId,
state: normalized,
}),
);
return true;
}
function renderCollaborationStatus() {
if (collabStatus) {
collabStatus.textContent = collaboration.connected && collaboration.ready
? `${collaboration.workspaceId} (live)`
: `${collaboration.workspaceId} (reconnecting…)`;
}
if (presenceStatus) {
const count = Math.max(1, Number(collaboration.presenceCount || 1));
presenceStatus.textContent = `${count} connected`;
}
}
function resolveWorkspaceId() {
const params = new URLSearchParams(window.location.search);
const raw = String(params.get("workspace") || DEFAULT_WORKSPACE_ID).trim();
if (!raw) return DEFAULT_WORKSPACE_ID;
return /^[a-zA-Z0-9._-]{1,64}$/.test(raw) ? raw : DEFAULT_WORKSPACE_ID;
}
async function runAssignmentGeneration() {
const topic = state.topic.trim();
const difficulty = state.difficulty.trim();
const count = clampInteger(state.questionCount) || 4;
if (!topic) {
showToast("Pick a topic before generating an assignment.", "error");
return;
}
if (!difficulty) {
showToast("Pick a difficulty before generating an assignment.", "error");
return;
}
generateAssignmentButton.disabled = true;
const originalLabel = generateAssignmentButton.textContent;
generateAssignmentButton.textContent = "Generating…";
try {
const response = await fetch("/api/assignment/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ topic, difficulty, count }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message || "Assignment generation failed.");
}
if (!Array.isArray(payload.questions) || payload.questions.length === 0) {
throw new Error("Assignment generator returned no questions.");
}
state.questions = payload.questions.map((question, index) => normalizeQuestion(question, index));
primeQuestionCollapsing(state.questions.length);
state.questionCount = String(state.questions.length);
state.generatorSeed = payload.seed ? String(payload.seed) : "";
if (!state.assignmentId) state.assignmentId = buildSuggestedAssignmentId(state.topic, state.difficulty);
if (!state.assignmentTitle) state.assignmentTitle = buildSuggestedAssignmentTitle(state.topic, state.difficulty, state.questions.length);
if (!state.instructions) state.instructions = "Show your working for each question.";
if (!state.studentId) state.studentId = "student-001";
if (!state.passThreshold) state.passThreshold = "0.70";
renderAll();
showToast("Assignment context filled from backend generator.", "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "Assignment generation failed.", "error");
} finally {
generateAssignmentButton.disabled = false;
generateAssignmentButton.textContent = originalLabel;
}
}
async function runAssignmentDraft({ button, path, apply, successMessage }) {
button.disabled = true;
const originalLabel = button.textContent;
button.textContent = "Working…";
try {
const response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(buildAssignmentPayload(state)),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message || "AI request failed.");
}
apply(payload);
renderAll();
showToast(successMessage, "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "AI request failed.", "error");
} finally {
button.disabled = false;
button.textContent = originalLabel;
}
}
function saveCurrentExample() {
const normalized = normalizeState(state);
const derived = buildDerivedExample(normalized);
const errors = validateStateForDataset(normalized, derived.record.teacherReview);
if (errors.length > 0) {
throw new Error(`Save blocked: ${errors[0]}`);
}
const timestamp = new Date().toISOString();
const example = {
id: activeExampleId || crypto.randomUUID(),
savedAt: timestamp,
title: deriveTitle(normalized),
assignmentId: normalized.assignmentId,
studentId: normalized.studentId,
questionCount: normalized.questions.length,
topic: normalized.topic,
difficulty: normalized.difficulty,
state: normalized,
record: derived.record,
trainingExample: derived.trainingExample,
};
const existingIndex = savedExamples.findIndex((item) => item.id === example.id);
if (existingIndex >= 0) {
savedExamples.splice(existingIndex, 1, example);
} else {
savedExamples.unshift(example);
}
activeExampleId = example.id;
persistDataset();
renderDataset();
showToast(existingIndex >= 0 ? "Saved example updated." : "Saved assignment example added to local dataset.", "success");
}
function loadSavedExample(id) {
const example = savedExamples.find((item) => item.id === id);
if (!example) {
showToast("Saved example not found.", "error");
return;
}
activeExampleId = id;
state = normalizeState(example.state || createEmptyState());
primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
renderAll();
showToast("Loaded saved assignment example into the workspace.", "success");
}
function deleteSavedExample(id) {
const example = savedExamples.find((item) => item.id === id);
if (!example) {
showToast("Saved example not found.", "error");
return;
}
savedExamples = savedExamples.filter((item) => item.id !== id);
if (activeExampleId === id) {
activeExampleId = null;
}
persistDataset();
renderDataset();
showToast(`Deleted “${example.title}”.`, "success");
}
function exportDatasetJsonl() {
const prepared = prepareSavedExamplesForExport();
const jsonl = prepared.map((entry) => JSON.stringify(entry.trainingExample)).join("\n");
downloadTextFile("dataset.jsonl", `${jsonl}\n`);
showToast(`Exported ${prepared.length} example${prepared.length === 1 ? "" : "s"} to dataset.jsonl.`, "success");
}
function exportTrainValidationSplit() {
const prepared = prepareSavedExamplesForExport();
if (prepared.length < 2) {
throw new Error("Need at least 2 saved examples before exporting a train/val split.");
}
const ordered = [...prepared].sort((left, right) => {
const leftTime = left.savedAt ? Date.parse(left.savedAt) : 0;
const rightTime = right.savedAt ? Date.parse(right.savedAt) : 0;
return leftTime - rightTime;
});
const validationCount = Math.max(1, Math.round(ordered.length * TRAIN_VAL_SPLIT));
const splitIndex = Math.max(1, ordered.length - validationCount);
const train = ordered.slice(0, splitIndex);
const validation = ordered.slice(splitIndex);
downloadTextFile("train.jsonl", `${train.map((entry) => JSON.stringify(entry.trainingExample)).join("\n")}\n`);
downloadTextFile("val.jsonl", `${validation.map((entry) => JSON.stringify(entry.trainingExample)).join("\n")}\n`);
showToast(
`Exported ${train.length} train and ${validation.length} validation example${validation.length === 1 ? "" : "s"}.`,
"success",
);
}
function prepareSavedExamplesForExport() {
if (savedExamples.length === 0) {
throw new Error("No saved examples yet. Save at least one example first.");
}
const invalidExamples = [];
const prepared = savedExamples.map((example) => {
const normalized = normalizeState(example.state || createEmptyState());
const derived = buildDerivedExample(normalized);
const errors = validateStateForDataset(normalized, derived.record.teacherReview);
if (errors.length > 0) {
invalidExamples.push(`${example.title}: ${errors[0]}`);
}
return {
...example,
record: derived.record,
trainingExample: derived.trainingExample,
};
});
if (invalidExamples.length > 0) {
throw new Error(`Fix saved examples before export. First issue: ${invalidExamples[0]}`);
}
return prepared;
}
function buildDerivedExample(currentState) {
const normalized = normalizeState(currentState);
const assignmentQuestions = normalized.questions.map((question, index) => ({
questionId: clampInteger(question.questionId),
position: index + 1,
title: question.title || `Question ${index + 1}`,
prompt: question.prompt,
correctAnswer: question.correctAnswer,
tags: splitTags(question.tags),
workedSolution: splitLines(question.workedSolution),
subject: question.subject || "Mathematics",
source: question.source || "rng_generated",
difficulty: question.difficulty || normalized.difficulty,
}));
const studentSubmissionQuestions = normalized.questions.map((question, index) => ({
questionId: clampInteger(question.questionId),
position: index + 1,
answerText: question.studentAnswer,
workingSteps: question.workingSteps,
solveMode: question.solveMode || "show_work",
}));
const teacherReviewQuestions = normalized.questions.map((question) => ({
questionId: clampInteger(question.questionId),
aiFeedback: question.aiFeedback,
understandingScore: clampScore(question.understandingScore),
confidence: clampScore(question.confidence),
needsAttention: parseBoolean(question.needsAttention),
issueReason: question.issueReason,
}));
const record = {
version: "assignment-review-v1",
metadata: {
createdAt: new Date().toISOString(),
topic: normalized.topic,
difficulty: normalized.difficulty,
questionCount: normalized.questions.length,
},
assignment: {
assignmentId: normalized.assignmentId,
assignmentTitle: normalized.assignmentTitle,
instructions: normalized.instructions,
passThreshold: clampScore(normalized.passThreshold),
questions: assignmentQuestions,
},
studentSubmission: {
studentId: normalized.studentId,
questions: studentSubmissionQuestions,
},
teacherReview: {
questions: teacherReviewQuestions,
assignmentSummary: normalized.assignmentSummary,
recommendedNextStep: normalized.recommendedNextStep,
},
};
const trainingExample = {
messages: [
{
role: "system",
content:
"You are an expert teacher reviewer. Review the full assignment in one pass. Return a JSON object with question-level teacher review labels for every question plus assignmentSummary and recommendedNextStep.",
},
{
role: "user",
content: JSON.stringify(
{
assignment: record.assignment,
studentSubmission: record.studentSubmission,
},
null,
2,
),
},
{
role: "assistant",
content: JSON.stringify(record.teacherReview, null, 2),
},
],
metadata: {
version: "assignment-review-v1",
assignmentId: normalized.assignmentId,
studentId: normalized.studentId,
questionCount: normalized.questions.length,
topic: normalized.topic,
difficulty: normalized.difficulty,
},
};
return { record, trainingExample };
}
function validateStateForDataset(currentState, teacherReview) {
const errors = [];
if (!currentState.assignmentId) errors.push("Assignment ID is required.");
if (!currentState.studentId) errors.push("Student ID is required.");
if (!currentState.assignmentTitle) errors.push("Assignment title is required.");
if (currentState.questions.length === 0) errors.push("At least one question is required.");
if (!currentState.assignmentSummary) errors.push("Assignment summary is required.");
if (!currentState.recommendedNextStep) errors.push("Recommended next step is required.");
for (const [index, question] of currentState.questions.entries()) {
const label = `Question ${index + 1}`;
if (!clampInteger(question.questionId)) errors.push(`${label} needs a valid question ID.`);
if (!question.prompt) errors.push(`${label} prompt is required.`);
if (!question.correctAnswer) errors.push(`${label} correct answer is required.`);
if (!question.workedSolution) errors.push(`${label} worked solution is required.`);
if (!question.studentAnswer && !question.workingSteps) errors.push(`${label} needs student work.`);
if (!question.aiFeedback) errors.push(`${label} AI feedback is required.`);
if (teacherReview.questions[index]?.understandingScore === null) errors.push(`${label} understanding score must be between 0.00 and 1.00.`);
if (teacherReview.questions[index]?.confidence === null) errors.push(`${label} confidence must be between 0.00 and 1.00.`);
if (teacherReview.questions[index]?.needsAttention === null) errors.push(`${label} needsAttention must be true or false.`);
if (!question.issueReason) errors.push(`${label} issue reason is required.`);
}
return errors;
}
function deriveTitle(currentState) {
const count = currentState.questions.length;
const topic = currentState.topic || "assignment";
return `${currentState.assignmentTitle || "Untitled assignment"}${count} question${count === 1 ? "" : "s"}${topic}`;
}
function buildDatasetPills(example) {
const pills = [];
if (example.topic) pills.push(`topic ${example.topic}`);
if (example.difficulty) pills.push(example.difficulty);
if (example.questionCount) pills.push(`${example.questionCount} questions`);
const reviewed = example.state?.questions?.filter((question) => question.aiFeedback).length || 0;
if (reviewed) pills.push(`${reviewed} labeled`);
return pills;
}
function buildAssignmentPayload(currentState) {
const normalized = normalizeState(currentState);
return {
assignmentId: normalized.assignmentId,
studentId: normalized.studentId,
assignmentTitle: normalized.assignmentTitle,
instructions: normalized.instructions,
passThreshold: clampScore(normalized.passThreshold),
topic: normalized.topic,
difficulty: normalized.difficulty,
questions: normalized.questions.map((question, index) => ({
questionId: clampInteger(question.questionId),
position: index + 1,
title: question.title,
prompt: question.prompt,
subject: question.subject,
source: question.source,
difficulty: question.difficulty,
correctAnswer: question.correctAnswer,
workedSolution: question.workedSolution,
tags: splitTags(question.tags),
studentAnswer: question.studentAnswer,
workingSteps: question.workingSteps,
solveMode: question.solveMode,
aiFeedback: question.aiFeedback,
understandingScore: clampScore(question.understandingScore),
confidence: clampScore(question.confidence),
needsAttention: parseBoolean(question.needsAttention),
issueReason: question.issueReason,
})),
};
}
function normalizeState(raw = {}) {
const next = createEmptyState();
for (const key of fixedFieldIds) {
next[key] = typeof raw[key] === "string" ? raw[key] : next[key];
}
next.questions = Array.isArray(raw.questions) && raw.questions.length
? raw.questions.map((question, index) => normalizeQuestion(question, index))
: [];
if (!next.questions.length && raw.questionPrompt) {
next.questions = [normalizeLegacyQuestion(raw)];
}
next.questionCount = String(next.questions.length || clampInteger(raw.questionCount) || 0);
return next;
}
function normalizeLegacyQuestion(raw) {
return normalizeQuestion(
{
questionId: raw.questionId,
title: raw.subject || "Legacy question",
prompt: raw.questionPrompt,
subject: raw.subject || "Mathematics",
source: "legacy_helper",
difficulty: raw.difficulty || "",
correctAnswer: raw.correctAnswer,
workedSolution: raw.correctReasoning,
tags: raw.tags,
studentAnswer: raw.studentAnswer,
workingSteps: raw.studentReasoning,
solveMode: "show_work",
aiFeedback: raw.aiFeedback,
understandingScore: raw.understandingScore,
confidence: raw.confidence,
needsAttention: raw.needsAttention,
issueReason: raw.issueReason,
},
0,
);
}
function normalizeQuestion(question = {}, index = 0) {
return {
questionId: stringValue(question.questionId),
position: index + 1,
title: stringValue(question.title),
prompt: stringValue(question.prompt),
subject: stringValue(question.subject) || "Mathematics",
source: stringValue(question.source) || "rng_generated",
difficulty: stringValue(question.difficulty),
correctAnswer: stringValue(question.correctAnswer),
workedSolution: Array.isArray(question.workedSolution)
? question.workedSolution.join("\n")
: stringValue(question.workedSolution),
tags: Array.isArray(question.tags) ? question.tags.join(", ") : stringValue(question.tags),
studentAnswer: stringValue(question.studentAnswer),
workingSteps: stringValue(question.workingSteps),
solveMode: stringValue(question.solveMode) || "show_work",
aiFeedback: stringValue(question.aiFeedback),
understandingScore: stringValue(question.understandingScore),
confidence: stringValue(question.confidence),
needsAttention: stringValue(question.needsAttention),
issueReason: stringValue(question.issueReason),
};
}
function getQuestionUiKey(question, index) {
return `${stringValue(question.questionId) || `idx-${index + 1}`}`;
}
function isQuestionCollapsed(index) {
return uiState.collapsedQuestions.has(getQuestionUiKey(state.questions[index], index));
}
function toggleQuestionCollapsed(index) {
const key = getQuestionUiKey(state.questions[index], index);
if (uiState.collapsedQuestions.has(key)) {
uiState.collapsedQuestions.delete(key);
} else {
uiState.collapsedQuestions.add(key);
}
}
function expandQuestionAtIndex(index) {
if (!state.questions[index]) return;
uiState.collapsedQuestions.delete(getQuestionUiKey(state.questions[index], index));
}
function primeQuestionCollapsing(questionCount, options = {}) {
const preserveExisting = options.preserveExisting ?? true;
if (!preserveExisting) {
uiState.collapsedQuestions.clear();
}
if (questionCount <= 2) {
uiState.collapsedQuestions.clear();
return;
}
uiState.collapsedQuestions = new Set(
state.questions
.map((question, index) => ({ key: getQuestionUiKey(question, index), index }))
.filter(({ index }) => index > 0)
.map(({ key }) => key),
);
uiState.collapsedQuestions.delete(getQuestionUiKey(state.questions[0], 0));
}
function matchesQuestionFilter(question) {
if (uiState.questionFilter === "attention") {
return parseBoolean(question.needsAttention) === true;
}
if (uiState.questionFilter === "unlabeled") {
return !question.aiFeedback || !question.issueReason;
}
return true;
}
function hasStudentWork(question) {
return Boolean(question.studentAnswer || question.workingSteps);
}
function isQuestionReviewed(question) {
return Boolean(question.aiFeedback && question.issueReason);
}
function buildQuestionStatusPills(question) {
const pills = [];
pills.push(`<span class="dataset-pill ${hasStudentWork(question) ? "pill-success" : "pill-muted"}">${hasStudentWork(question) ? "Student drafted" : "No student work"}</span>`);
pills.push(`<span class="dataset-pill ${isQuestionReviewed(question) ? "pill-success" : "pill-muted"}">${isQuestionReviewed(question) ? "Teacher labeled" : "Needs labels"}</span>`);
if (parseBoolean(question.needsAttention) === true) {
pills.push('<span class="dataset-pill pill-warning">Needs attention</span>');
}
if (question.understandingScore) {
pills.push(`<span class="dataset-pill">Understanding ${escapeHtml(question.understandingScore)}</span>`);
}
if (question.confidence) {
pills.push(`<span class="dataset-pill">Confidence ${escapeHtml(question.confidence)}</span>`);
}
return pills.join("");
}
function reindexQuestions() {
state.questions = state.questions.map((question, index) => ({
...question,
position: index + 1,
}));
state.questionCount = String(state.questions.length);
}
function createEmptyState() {
return {
assignmentId: "",
studentId: "",
assignmentTitle: "",
instructions: "",
passThreshold: "0.70",
topic: "",
difficulty: "",
questionCount: "0",
generatorSeed: "",
assignmentSummary: "",
recommendedNextStep: "",
questions: [],
};
}
function createBlankQuestion(position) {
return normalizeQuestion({
questionId: "",
title: `Question ${position}`,
prompt: "",
subject: "Mathematics",
source: "manual",
difficulty: state.difficulty || "",
correctAnswer: "",
workedSolution: "",
tags: "",
studentAnswer: "",
workingSteps: "",
solveMode: "show_work",
aiFeedback: "",
understandingScore: "",
confidence: "",
needsAttention: "",
issueReason: "",
}, position - 1);
}
function createSampleState() {
return normalizeState({
assignmentId: "assignment-fractions-01",
studentId: "student-17",
assignmentTitle: "Fractions review",
instructions: "Show all working and explain your reasoning when you can.",
passThreshold: "0.70",
topic: "fractions",
difficulty: "medium",
questionCount: "2",
generatorSeed: "sample-seed",
assignmentSummary:
"The student shows a workable idea for finding a fraction of an amount, but the assignment reveals inconsistent understanding when simplifying and converting between equivalent forms.",
recommendedNextStep:
"Reinforce fraction simplification and equivalent-fraction checks with one worked example before asking the student to retry a similar mixed set.",
questions: [
{
questionId: "401",
title: "Find a fraction of an amount",
prompt: "Find 3/4 of 20.",
subject: "Mathematics",
source: "rng_generated",
difficulty: "medium",
correctAnswer: "15",
workedSolution: "First find 1/4 of 20 by dividing 20 by 4 to get 5. Then multiply 5 by 3 to get 15.",
tags: "fractions, fraction_of_amount, rng_generated",
studentAnswer: "15",
workingSteps: "I did 20 divided by 4 which is 5, then 5 times 3 is 15.",
solveMode: "show_work",
aiFeedback:
"The student uses the correct two-step method and explains the quarter-then-multiply structure clearly, showing secure understanding for this question.",
understandingScore: "0.90",
confidence: "0.95",
needsAttention: "false",
issueReason: "The response is correct and the working shows sound understanding of finding a fraction of an amount.",
},
{
questionId: "402",
title: "Simplify a fraction",
prompt: "Simplify 6/8.",
subject: "Mathematics",
source: "rng_generated",
difficulty: "medium",
correctAnswer: "3/4",
workedSolution: "Divide numerator and denominator by their greatest common factor, 2. 6 ÷ 2 = 3 and 8 ÷ 2 = 4, so 6/8 = 3/4.",
tags: "fractions, simplify, rng_generated",
studentAnswer: "2/4",
workingSteps: "I cut both numbers in half, so 6 became 2 and 8 became 4.",
solveMode: "show_work",
aiFeedback:
"The student knows the fraction should be reduced but does not preserve the equivalence correctly. Their numerator change suggests shaky control of the simplification step rather than secure understanding.",
understandingScore: "0.42",
confidence: "0.89",
needsAttention: "true",
issueReason:
"The student attempted to simplify but changed 6 to 2 instead of 3, so the reduced fraction is not equivalent to the original.",
},
],
});
}
function buildSuggestedAssignmentId(topic, difficulty) {
return `assignment-${topic || "topic"}-${difficulty || "level"}-${Date.now()}`;
}
function buildSuggestedAssignmentTitle(topic, difficulty, count) {
const topicLabel = topic ? topic.replace(/_/g, " ") : "Mixed maths";
return `${capitalize(topicLabel)} ${difficulty || ""} review • ${count} question${count === 1 ? "" : "s"}`.trim();
}
function capitalize(text) {
return text ? text.charAt(0).toUpperCase() + text.slice(1) : "";
}
function splitTags(tags) {
return String(tags || "")
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
function splitLines(text) {
return String(text || "")
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean);
}
function clampScore(raw) {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return null;
return Math.max(0, Math.min(1, Number(parsed.toFixed(2))));
}
function formatScore(value) {
if (typeof value !== "number" || Number.isNaN(value)) return "";
return Math.max(0, Math.min(1, value)).toFixed(2);
}
function clampInteger(raw) {
const parsed = Number.parseInt(raw, 10);
if (!Number.isInteger(parsed) || parsed < 1) return null;
return parsed;
}
function parseBoolean(raw) {
if (raw === true || raw === "true") return true;
if (raw === false || raw === "false") return false;
return null;
}
function formatBoolean(value) {
if (value === true) return "true";
if (value === false) return "false";
return "";
}
function stringValue(value) {
if (value === null || value === undefined) return "";
return String(value).trim();
}
function summarizeText(text, limit) {
if (!text) return "";
const normalized = text.replace(/\s+/g, " ").trim();
if (normalized.length <= limit) return normalized;
return `${normalized.slice(0, Math.max(0, limit - 1)).trimEnd()}`;
}
function renderSelectOptions(options, selected) {
return options
.map((option) => `<option value="${escapeAttribute(option)}" ${option === selected ? "selected" : ""}>${escapeHtml(option)}</option>`)
.join("");
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeAttribute(value) {
return escapeHtml(value).replaceAll("'", "&#39;");
}
function isSavedExampleShape(value) {
return Boolean(value && typeof value === "object" && typeof value.id === "string" && value.id);
}
async function copyPreview(text, successMessage) {
try {
await navigator.clipboard.writeText(text);
showToast(successMessage, "success");
} catch {
showToast("Clipboard copy failed. You can still select and copy manually.", "error");
}
}
function downloadTextFile(filename, content) {
const blob = new Blob([content], { type: "application/x-ndjson;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function showToast(message, tone) {
toast.textContent = message;
toast.className = `toast ${tone}`;
clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => {
toast.className = "toast hidden";
}, 3200);
}