diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx
index a4346e5..501060e 100644
--- a/src/AppRoutes.admin.test.tsx
+++ b/src/AppRoutes.admin.test.tsx
@@ -31,7 +31,10 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () =>
Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () =>
Public Home
}));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single Player
}));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
}));
-vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
}));
+vi.mock('./pages/exam', () => ({
+ ExamDashboardPage: () =>
Exam Marker
,
+ MarkSchemePage: () =>
Mark Scheme editor
,
+}));
vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
}));
vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
}));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () =>
TLDraw Dev
}));
@@ -122,3 +125,22 @@ describe('/admin route authorization', () => {
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
});
});
+
+
+describe('exam-marker routes', () => {
+ beforeEach(() => {
+ mockUseAuth.mockReset();
+ });
+
+ it('renders the mark scheme editor route for authenticated users', () => {
+ mockUseAuth.mockReturnValue(authState({
+ user: { id: 'teacher-1', email: 'teacher@example.com', user_type: 'email_teacher' },
+ user_role: 'email_teacher',
+ accessToken: 'token',
+ }));
+
+ renderAt('/exam-marker/template-123/marks');
+
+ expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
+ });
+});
diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx
index f091bfb..4c3330c 100644
--- a/src/AppRoutes.tsx
+++ b/src/AppRoutes.tsx
@@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser';
-import { ExamDashboardPage } from './pages/exam';
+import { ExamDashboardPage, MarkSchemePage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
@@ -183,6 +183,7 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx
index 500c45f..5080792 100644
--- a/src/pages/exam/ExamDashboardPage.tsx
+++ b/src/pages/exam/ExamDashboardPage.tsx
@@ -22,6 +22,7 @@ import {
import AddIcon from '@mui/icons-material/Add';
import ArchiveIcon from '@mui/icons-material/Archive';
import AssignmentIcon from '@mui/icons-material/Assignment';
+import GradingIcon from '@mui/icons-material/Grading';
import { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository';
@@ -168,6 +169,16 @@ const ExamDashboardPage: React.FC = () => {
{new Date(t.updated_at).toLocaleDateString()}
+
+
+
))}
diff --git a/src/pages/exam/MarkSchemePage.tsx b/src/pages/exam/MarkSchemePage.tsx
new file mode 100644
index 0000000..39cd206
--- /dev/null
+++ b/src/pages/exam/MarkSchemePage.tsx
@@ -0,0 +1,501 @@
+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 (
+
+
+
+
+ } onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
+ Back to templates
+
+ Mark Scheme editor
+
+ {template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
+
+
+
+ {templateId && }
+ } onClick={syncGraph} disabled={syncing || !templateId}>
+ {syncing ? 'Syncing…' : 'Sync to graph'}
+
+
+
+
+ {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'}
+
+
+ }
+ disabled={savingId === selected.id}
+ onClick={() => savePart(selected)}
+ >
+ {savingId === selected.id ? 'Saving…' : 'Save part'}
+
+
+
+
+
+ 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;
diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts
index d8b8c5c..7f5a856 100644
--- a/src/pages/exam/index.ts
+++ b/src/pages/exam/index.ts
@@ -1 +1,2 @@
export { default as ExamDashboardPage } from './ExamDashboardPage';
+export { default as MarkSchemePage } from './MarkSchemePage';
diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts
index 1b5160d..d9fd7e5 100644
--- a/src/services/exam/examRepository.ts
+++ b/src/services/exam/examRepository.ts
@@ -13,6 +13,9 @@ import type {
CreateTemplatePayload,
ExamTemplate,
ExamTemplateDetail,
+ Neo4jSyncResult,
+ PatchQuestionPayload,
+ SpecPoint,
} from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`;
@@ -51,6 +54,28 @@ export const examRepository = {
const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
},
+
+ async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
+ const headers = await authHeaders();
+ const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
+ return res.data;
+ },
+
+ async listSpecPoints(specCode: string, search?: string): Promise {
+ const headers = await authHeaders();
+ const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
+ `${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
+ { headers, params: search ? { q: search } : undefined },
+ );
+ if (Array.isArray(res.data)) return res.data;
+ return res.data.points ?? [];
+ },
+
+ async syncTemplateToGraph(templateId: string): Promise {
+ const headers = await authHeaders();
+ const res = await axios.post(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
+ return res.data;
+ },
};
export default examRepository;
diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts
index 017e0d1..6f12c2d 100644
--- a/src/types/exam.types.ts
+++ b/src/types/exam.types.ts
@@ -31,6 +31,42 @@ export interface CreateTemplatePayload {
institute_id?: string;
}
+export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
+
+export interface MarkSchemePoint {
+ mark: number;
+ text: string;
+}
+
+export interface MarkSchemeLevel {
+ level: string;
+ min: number;
+ max: number;
+ descriptor: string;
+}
+
+export interface MarkSchemePart {
+ label: string;
+ marks: number;
+ guidance: string;
+}
+
+export interface MarkSchemeChecklistItem {
+ text: string;
+ marks: number;
+}
+
+export interface MarkScheme {
+ type?: MarkSchemeType;
+ points?: MarkSchemePoint[];
+ levels?: MarkSchemeLevel[];
+ parts?: MarkSchemePart[];
+ checklist?: MarkSchemeChecklistItem[];
+ text?: string;
+ notes?: string;
+ [key: string]: unknown;
+}
+
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
export interface ExamQuestion {
id: string;
@@ -41,9 +77,11 @@ export interface ExamQuestion {
max_marks: number;
answer_type: string | null;
mcq_options: unknown | null;
- mark_scheme: Record;
+ mark_scheme: MarkScheme;
is_container: boolean;
spec_ref: string | null;
+ bounds?: Record | null;
+ page?: number | null;
}
export interface ExamResponseArea {
@@ -52,8 +90,9 @@ export interface ExamResponseArea {
template_id: string;
page: number;
bounds: Record;
- kind: 'response' | 'context';
+ kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
response_form: string | null;
+ context_type?: string | null;
source: 'manual' | 'ai';
confirmed: boolean;
confidence: number | null;
@@ -76,3 +115,28 @@ export interface ExamTemplateDetail extends ExamTemplate {
response_areas: ExamResponseArea[];
boundaries: ExamBoundary[];
}
+
+export interface PatchQuestionPayload {
+ label?: string;
+ order?: number;
+ max_marks?: number;
+ answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
+ mcq_options?: unknown;
+ mark_scheme?: MarkScheme;
+ is_container?: boolean;
+ spec_ref?: string | null;
+}
+
+export interface SpecPoint {
+ uid?: string;
+ uuid_string?: string;
+ ref: string;
+ description: string;
+ spec_code: string;
+ exam_board_code?: string;
+}
+
+export interface Neo4jSyncResult {
+ status: string;
+ projection?: Record;
+}