import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, Autocomplete, Box, Button, Card, CardContent, Chip, CircularProgress, Container, Divider, FormControl, Grid, InputLabel, MenuItem, Paper, Select, Stack, Tab, Tabs, TextField, Typography, } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import SaveIcon from '@mui/icons-material/Save'; import SyncIcon from '@mui/icons-material/Sync'; import { logger } from '../../debugConfig'; import { examRepository } from '../../services/exam/examRepository'; import type { ExamQuestion, ExamTemplateDetail, MarkScheme, MarkSchemeType, SpecPoint, } from '../../types/exam.types'; type Drafts = Record; interface PartDraft { max_marks: string; answer_type: 'written' | 'mcq' | 'short' | 'diagram'; spec_ref: string; schemeType: MarkSchemeType; body: string; notes: string; } const ANSWER_TYPES: Array = ['written', 'short', 'mcq', 'diagram']; const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [ { value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' }, { value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' }, { value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' }, { value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' }, { value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' }, ]; function deriveSpecCode(template: ExamTemplateDetail | null): string { const examCode = template?.exam_code ?? ''; const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i); return match?.[1] ?? ''; } function schemeToDraft(scheme: MarkScheme | null | undefined): Pick { const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points'; const notes = typeof scheme?.notes === 'string' ? scheme.notes : ''; if (type === 'levels' && Array.isArray(scheme?.levels)) { return { schemeType: 'levels', body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'), notes, }; } if (type === 'parts' && Array.isArray(scheme?.parts)) { return { schemeType: 'parts', body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'), notes, }; } if (type === 'checklist' && Array.isArray(scheme?.checklist)) { return { schemeType: 'checklist', body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'), notes, }; } if (type === 'free') { return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes }; } if (Array.isArray(scheme?.points)) { return { schemeType: 'points', body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'), notes, }; } return { schemeType: type, body: '', notes }; } function parseNumber(value: string, fallback = 0): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function splitLine(line: string): string[] { return line.split('|').map((part) => part.trim()); } function draftToScheme(draft: PartDraft): MarkScheme { const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean); if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined }; if (draft.schemeType === 'levels') { return { type: 'levels', levels: lines.map((line, idx) => { const [level, range, descriptor] = splitLine(line); const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim()); return { level: level || `L${idx + 1}`, min: parseNumber(minRaw, 0), max: parseNumber(maxRaw, parseNumber(minRaw, 0)), descriptor: descriptor || line, }; }), notes: draft.notes || undefined, }; } if (draft.schemeType === 'parts') { return { type: 'parts', parts: lines.map((line, idx) => { const [label, marks, guidance] = splitLine(line); return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line }; }), notes: draft.notes || undefined, }; } if (draft.schemeType === 'checklist') { return { type: 'checklist', checklist: lines.map((line) => { const [marks, text] = splitLine(line); return { marks: parseNumber(marks, 1), text: text || line }; }), notes: draft.notes || undefined, }; } return { type: 'points', points: lines.map((line) => { const [mark, text] = splitLine(line); return { mark: parseNumber(mark, 1), text: text || line }; }), notes: draft.notes || undefined, }; } function makeDraft(question: ExamQuestion): PartDraft { const scheme = schemeToDraft(question.mark_scheme); return { max_marks: String(question.max_marks ?? 0), answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written', spec_ref: question.spec_ref ?? '', schemeType: scheme.schemeType, body: scheme.body, notes: scheme.notes, }; } const MarkSchemePage: React.FC = () => { const navigate = useNavigate(); const { templateId } = useParams<{ templateId: string }>(); const [template, setTemplate] = useState(null); const [selectedId, setSelectedId] = useState(null); const [drafts, setDrafts] = useState({}); const [specCode, setSpecCode] = useState(''); const [specPoints, setSpecPoints] = useState([]); const [loading, setLoading] = useState(true); const [savingId, setSavingId] = useState(null); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [specEndpointMissing, setSpecEndpointMissing] = useState(false); const load = useCallback(async () => { if (!templateId) return; setLoading(true); setError(null); try { const detail = await examRepository.getTemplate(templateId); const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)); setTemplate(detail); setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null); setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)]))); setSpecCode((prev) => prev || deriveSpecCode(detail)); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId }); setError(msg); } finally { setLoading(false); } }, [templateId]); useEffect(() => { void load(); }, [load]); const loadSpecPoints = useCallback(async () => { if (!specCode.trim()) return; setSpecEndpointMissing(false); try { setSpecPoints(await examRepository.listSpecPoints(specCode.trim())); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode }); setSpecEndpointMissing(true); setSpecPoints([]); } }, [specCode]); useEffect(() => { void loadSpecPoints(); }, [loadSpecPoints]); const parts = useMemo( () => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)), [template], ); const selected = parts.find((p) => p.id === selectedId) ?? null; const draft = selected ? drafts[selected.id] : null; const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null; const updateDraft = (questionId: string, patch: Partial) => { setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } })); }; const savePart = async (question: ExamQuestion) => { const partDraft = drafts[question.id]; if (!partDraft) return; setSavingId(question.id); setError(null); setNotice(null); try { await examRepository.patchQuestion(question.id, { max_marks: parseNumber(partDraft.max_marks, question.max_marks), answer_type: partDraft.answer_type, mark_scheme: draftToScheme(partDraft), spec_ref: partDraft.spec_ref.trim() || null, }); setTemplate((prev) => { if (!prev) return prev; return { ...prev, questions: prev.questions.map((q) => q.id === question.id ? { ...q, max_marks: parseNumber(partDraft.max_marks, question.max_marks), answer_type: partDraft.answer_type, mark_scheme: draftToScheme(partDraft), spec_ref: partDraft.spec_ref.trim() || null, } : q), }; }); setNotice(`Saved mark scheme for Part ${question.label}.`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id }); setError(msg); } finally { setSavingId(null); } }; const syncGraph = async () => { if (!templateId) return; setSyncing(true); setError(null); setNotice(null); try { const res = await examRepository.syncTemplateToGraph(templateId); setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId }); setError(msg); } finally { setSyncing(false); } }; if (loading) { return ; } return ( Mark Scheme editor {template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points. {templateId && } {error && setError(null)}>{error}} {notice && setNotice(null)}>{notice}} {specEndpointMissing && ( SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync. )} setSpecCode(e.target.value)} fullWidth helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker." /> Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]->(:SpecPoint). {parts.length === 0 ? ( This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes. ) : ( Parts setSelectedId(value)} variant="scrollable" sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }} > {parts.map((part) => ( Part {part.label} {(drafts[part.id]?.spec_ref || part.spec_ref) && } } /> ))} {selected && draft && ( Part {selected.label} {selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'} updateDraft(selected.id, { max_marks: e.target.value })} type="number" inputProps={{ min: 0, step: 0.5 }} fullWidth /> Answer type Scheme form updateDraft(selected.id, { spec_ref: value?.ref ?? '' })} getOptionLabel={(option) => `${option.ref} — ${option.description}`} isOptionEqualToValue={(option, value) => option.ref === value.ref} renderInput={(params) => ( )} /> updateDraft(selected.id, { spec_ref: e.target.value })} helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint." fullWidth /> type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`} value={draft.body} onChange={(e) => updateDraft(selected.id, { body: e.target.value })} helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper} multiline minRows={10} fullWidth /> updateDraft(selected.id, { notes: e.target.value })} multiline minRows={3} fullWidth /> )} )} ); }; export default MarkSchemePage;