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 ( + + + + + + 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; 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; +}