Compare commits

..
Author SHA1 Message Date
kcar fdbc19cf0d feat(exam): version templates on dashboard 2026-06-07 00:33:06 +01:00
7 changed files with 362 additions and 687 deletions
+1 -23
View File
@@ -31,10 +31,7 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> })); vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> })); vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> })); vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
vi.mock('./pages/exam', () => ({ vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
ExamDashboardPage: () => <div>Exam Marker</div>,
MarkSchemePage: () => <div>Mark Scheme editor</div>,
}));
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> })); vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> })); vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> })); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
@@ -125,22 +122,3 @@ describe('/admin route authorization', () => {
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument(); 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: '[email protected]', user_type: 'email_teacher' },
user_role: 'email_teacher',
accessToken: 'token',
}));
renderAt('/exam-marker/template-123/marks');
expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
});
});
+1 -2
View File
@@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage'; import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage'; import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser'; import MultiplayerUser from './pages/tldraw/multiplayerUser';
import { ExamDashboardPage, MarkSchemePage } from './pages/exam'; import { ExamDashboardPage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage'; import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage'; import SettingsPage from './pages/user/settingsPage';
@@ -183,7 +183,6 @@ const AppRoutes: React.FC = () => {
<Route path="/search" element={<SearxngPage />} /> <Route path="/search" element={<SearxngPage />} />
<Route path="/teacher-planner" element={<TeacherPlanner />} /> <Route path="/teacher-planner" element={<TeacherPlanner />} />
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} /> <Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} /> <Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
<Route path="/morphic" element={<MorphicPage />} /> <Route path="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} /> <Route path="/tldraw-dev" element={<TLDrawDevPage />} />
+228 -74
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
@@ -22,7 +22,8 @@ import {
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import ArchiveIcon from '@mui/icons-material/Archive'; import ArchiveIcon from '@mui/icons-material/Archive';
import AssignmentIcon from '@mui/icons-material/Assignment'; import AssignmentIcon from '@mui/icons-material/Assignment';
import GradingIcon from '@mui/icons-material/Grading'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import EditIcon from '@mui/icons-material/Edit';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository'; import { examRepository } from '../../services/exam/examRepository';
@@ -35,6 +36,51 @@ const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> =
archived: 'default', archived: 'default',
}; };
const VERSION_SEPARATOR = ' · ';
const VERSION_RE = /(?:^|\s)[vV](\d+(?:\.\d+)*)$/;
type DialogMode = 'create' | 'edit' | 'duplicate';
type TemplateDialogState = {
mode: DialogMode;
template?: ExamTemplate;
} | null;
function splitTemplateTitle(title: string): { name: string; version: string } {
const parts = title.split(VERSION_SEPARATOR);
const possibleVersion = parts[parts.length - 1]?.trim() ?? '';
if (parts.length > 1 && VERSION_RE.test(possibleVersion)) {
return { name: parts.slice(0, -1).join(VERSION_SEPARATOR).trim(), version: possibleVersion };
}
return { name: title, version: 'v1' };
}
function composeTemplateTitle(name: string, version: string): string {
const cleanName = name.trim();
const cleanVersion = version.trim();
return cleanVersion ? `${cleanName}${VERSION_SEPARATOR}${cleanVersion}` : cleanName;
}
function nextVersionLabel(version: string): string {
const match = version.trim().match(VERSION_RE);
if (!match) return 'v2';
const segments = match[1].split('.');
const last = Number(segments[segments.length - 1]);
segments[segments.length - 1] = Number.isFinite(last) ? String(last + 1) : '2';
return `v${segments.join('.')}`;
}
function paperKey(t: ExamTemplate): string {
return t.exam_id ?? t.source_file_id ?? t.exam_code ?? t.subject ?? 'custom-paper';
}
function paperLabel(t: ExamTemplate): string {
if (t.exam_code) return t.exam_code;
if (t.subject) return t.subject;
if (t.source_file_id) return 'Uploaded paper';
return 'Custom paper';
}
const ExamDashboardPage: React.FC = () => { const ExamDashboardPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { bootstrapData } = useAuth(); const { bootstrapData } = useAuth();
@@ -44,8 +90,9 @@ const ExamDashboardPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [dialog, setDialog] = useState<TemplateDialogState>(null);
const [title, setTitle] = useState(''); const [templateName, setTemplateName] = useState('');
const [version, setVersion] = useState('v1');
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -67,33 +114,99 @@ const ExamDashboardPage: React.FC = () => {
void load(); void load();
}, [load, instituteId]); }, [load, instituteId]);
const handleCreate = async () => { const groupedTemplates = useMemo(() => {
if (!title.trim()) return; const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
templates.forEach((template) => {
const key = paperKey(template);
const existing = groups.get(key);
if (existing) {
existing.templates.push(template);
} else {
groups.set(key, { label: paperLabel(template), templates: [template] });
}
});
return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
}, [templates]);
const openCreate = () => {
setTemplateName('New template');
setVersion('v1');
setSubject('');
setDialog({ mode: 'create' });
};
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation();
const parsed = splitTemplateTitle(template.title);
setTemplateName(parsed.name);
setVersion(parsed.version);
setSubject(template.subject ?? '');
setDialog({ mode: 'edit', template });
};
const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation();
const parsed = splitTemplateTitle(template.title);
setTemplateName(parsed.name);
setVersion(nextVersionLabel(parsed.version));
setSubject(template.subject ?? '');
setDialog({ mode: 'duplicate', template });
};
const closeDialog = () => {
if (!saving) setDialog(null);
};
const handleSaveDialog = async () => {
if (!dialog || !templateName.trim()) return;
const title = composeTemplateTitle(templateName, version);
setSaving(true); setSaving(true);
try { try {
const created = await examRepository.createTemplate({ if (dialog.mode === 'create') {
title: title.trim(), const created = await examRepository.createTemplate({
subject: subject.trim() || undefined, title,
institute_id: instituteId, subject: subject.trim() || undefined,
institute_id: instituteId,
});
setDialog(null);
navigate(`/exam-marker/${created.id}/setup`);
return;
}
if (!dialog.template) return;
if (dialog.mode === 'duplicate') {
const created = await examRepository.duplicateTemplate(dialog.template.id, title);
setTemplates((prev) => [created, ...prev]);
setDialog(null);
navigate(`/exam-marker/${created.id}/setup`);
return;
}
const updated = await examRepository.updateTemplateMeta(dialog.template.id, {
title,
subject: subject.trim() || null,
}); });
setCreateOpen(false); setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setTitle(''); setDialog(null);
setSubject('');
navigate(`/exam-marker/${created.id}/setup`);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Create template failed', { message: msg }); logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
setError(msg); setError(msg);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleArchive = async (id: string, ev: React.MouseEvent) => { const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation(); ev.stopPropagation();
const parsed = splitTemplateTitle(template.title);
if (!window.confirm(`Archive ${parsed.name} ${parsed.version}? This hides it from the dashboard but keeps the work recoverable.`)) {
return;
}
try { try {
await examRepository.archiveTemplate(id); await examRepository.archiveTemplate(template.id);
setTemplates((prev) => prev.filter((t) => t.id !== id)); setTemplates((prev) => prev.filter((t) => t.id !== template.id));
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Archive failed', { message: msg }); logger.error('cc-exam-marker', 'Archive failed', { message: msg });
@@ -101,6 +214,12 @@ const ExamDashboardPage: React.FC = () => {
} }
}; };
const dialogTitle = dialog?.mode === 'edit'
? 'Rename template / edit version'
: dialog?.mode === 'duplicate'
? 'Duplicate as new version'
: 'New exam template';
return ( return (
<Container maxWidth="lg" sx={{ py: 6 }}> <Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={4}> <Stack spacing={4}>
@@ -109,11 +228,11 @@ const ExamDashboardPage: React.FC = () => {
<Typography variant="h3" component="h1" gutterBottom> <Typography variant="h3" component="h1" gutterBottom>
Exam Marker Exam Marker
</Typography> </Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}> <Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
Build a template for an exam paper, then run marking batches against your classes. Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
</Typography> </Typography>
</Box> </Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}> <Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
New template New template
</Button> </Button>
</Box> </Box>
@@ -133,83 +252,118 @@ const ExamDashboardPage: React.FC = () => {
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} /> <AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography variant="h6" gutterBottom>No exam templates yet</Typography> <Typography variant="h6" gutterBottom>No exam templates yet</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Create your first template to start mapping an exam paper. Create your first named template to start mapping an exam paper.
</Typography> </Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}> <Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
New template New template
</Button> </Button>
</Paper> </Paper>
) : ( ) : (
<Grid container spacing={3}> <Stack spacing={3}>
{templates.map((t) => ( {groupedTemplates.map((group) => (
<Grid item xs={12} sm={6} md={4} key={t.id}> <Box key={group.key}>
<Paper <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
elevation={2} <Typography variant="h6">{group.label}</Typography>
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1, <Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }} </Stack>
onClick={() => navigate(`/exam-marker/${t.id}/setup`)} <Grid container spacing={3}>
> {group.templates.map((t) => {
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}> const parsed = splitTemplateTitle(t.title);
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography> return (
<Tooltip title="Archive"> <Grid item xs={12} sm={6} md={4} key={t.id}>
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template"> <Paper
<ArchiveIcon fontSize="small" /> elevation={2}
</IconButton> sx={{
</Tooltip> p: 3,
</Box> height: '100%',
{t.subject && ( cursor: 'pointer',
<Typography variant="body2" color="text.secondary">{t.subject}</Typography> display: 'flex',
)} flexDirection: 'column',
{t.exam_code && ( gap: 1,
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography> transition: 'box-shadow 120ms',
)} '&:hover': { boxShadow: 6 },
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> }}
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" /> onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
<Typography variant="caption" color="text.secondary"> >
{new Date(t.updated_at).toLocaleDateString()} <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
</Typography> <Box sx={{ minWidth: 0 }}>
</Box> <Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
<Stack direction="row" spacing={1} sx={{ pt: 1 }}> <Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
<Button </Box>
size="small" <Stack direction="row" spacing={0.5}>
variant="outlined" <Tooltip title="Rename / edit version">
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }} <IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
startIcon={<GradingIcon fontSize="small" />} <EditIcon fontSize="small" />
> </IconButton>
Edit marks </Tooltip>
</Button> <Tooltip title="Duplicate as new version">
</Stack> <IconButton size="small" onClick={(e) => openDuplicate(t, e)} aria-label="duplicate template">
</Paper> <ContentCopyIcon fontSize="small" />
</Grid> </IconButton>
</Tooltip>
<Tooltip title="Archive">
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
<ArchiveIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
{t.subject && (
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
)}
{t.exam_code && (
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
)}
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
<Typography variant="caption" color="text.secondary">
Updated {new Date(t.updated_at).toLocaleDateString()}
</Typography>
</Box>
</Paper>
</Grid>
);
})}
</Grid>
</Box>
))} ))}
</Grid> </Stack>
)} )}
</Stack> </Stack>
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm"> <Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>New exam template</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}> <Stack spacing={2} sx={{ mt: 1 }}>
<TextField <TextField
label="Title" label="Template name"
value={title} value={templateName}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTemplateName(e.target.value)}
fullWidth fullWidth
autoFocus autoFocus
required required
helperText="User-facing name. Several templates can share the same paper."
/> />
<TextField <TextField
label="Subject" label="Version"
value={version}
onChange={(e) => setVersion(e.target.value)}
fullWidth
helperText="Stored in the template title until the API grows a dedicated version column."
/>
<TextField
label="Paper / subject label"
value={subject} value={subject}
onChange={(e) => setSubject(e.target.value)} onChange={(e) => setSubject(e.target.value)}
fullWidth fullWidth
disabled={dialog?.mode === 'duplicate'}
/> />
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button> <Button onClick={closeDialog} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}> <Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
{saving ? 'Creating…' : 'Create'} {saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
-501
View File
@@ -1,501 +0,0 @@
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<string, PartDraft>;
interface PartDraft {
max_marks: string;
answer_type: 'written' | 'mcq' | 'short' | 'diagram';
spec_ref: string;
schemeType: MarkSchemeType;
body: string;
notes: string;
}
const ANSWER_TYPES: Array<PartDraft['answer_type']> = ['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<PartDraft, 'schemeType' | 'body' | 'notes'> {
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<ExamTemplateDetail | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Drafts>({});
const [specCode, setSpecCode] = useState('');
const [specPoints, setSpecPoints] = useState<SpecPoint[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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<PartDraft>) => {
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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>;
}
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Stack spacing={3}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Back to templates
</Button>
<Typography variant="h3" component="h1">Mark Scheme editor</Typography>
<Typography variant="body1" color="text.secondary">
{template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
</Typography>
</Box>
<Stack direction="row" spacing={1}>
{templateId && <Button variant="outlined" onClick={() => navigate(`/exam-marker/${templateId}/setup`)}>Setup canvas</Button>}
<Button variant="outlined" startIcon={<SyncIcon />} onClick={syncGraph} disabled={syncing || !templateId}>
{syncing ? 'Syncing…' : 'Sync to graph'}
</Button>
</Stack>
</Box>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{notice && <Alert severity="success" onClose={() => setNotice(null)}>{notice}</Alert>}
{specEndpointMissing && (
<Alert severity="warning">
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.
</Alert>
)}
<Paper variant="outlined" sx={{ p: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={4}>
<TextField
label="Specification code"
value={specCode}
onChange={(e) => setSpecCode(e.target.value)}
fullWidth
helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker."
/>
</Grid>
<Grid item xs={12} md={8}>
<Typography variant="body2" color="text.secondary">
Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]-&gt;(:SpecPoint).
</Typography>
</Grid>
</Grid>
</Paper>
{parts.length === 0 ? (
<Alert severity="info">
This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes.
</Alert>
) : (
<Grid container spacing={3}>
<Grid item xs={12} md={3}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>Parts</Typography>
<Tabs
orientation="vertical"
value={selectedId ?? false}
onChange={(_, value) => setSelectedId(value)}
variant="scrollable"
sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }}
>
{parts.map((part) => (
<Tab
key={part.id}
value={part.id}
label={
<Box sx={{ textAlign: 'left', width: '100%' }}>
<Typography variant="body2">Part {part.label}</Typography>
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
<Chip size="small" label={`${drafts[part.id]?.max_marks ?? part.max_marks} marks`} />
{(drafts[part.id]?.spec_ref || part.spec_ref) && <Chip size="small" color="info" label={drafts[part.id]?.spec_ref || part.spec_ref || ''} />}
</Stack>
</Box>
}
/>
))}
</Tabs>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={9}>
{selected && draft && (
<Card variant="outlined">
<CardContent>
<Stack spacing={3}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box>
<Typography variant="h5">Part {selected.label}</Typography>
<Typography variant="body2" color="text.secondary">
{selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'}
</Typography>
</Box>
<Button
variant="contained"
startIcon={<SaveIcon />}
disabled={savingId === selected.id}
onClick={() => savePart(selected)}
>
{savingId === selected.id ? 'Saving…' : 'Save part'}
</Button>
</Box>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<TextField
label="Max marks"
value={draft.max_marks}
onChange={(e) => updateDraft(selected.id, { max_marks: e.target.value })}
type="number"
inputProps={{ min: 0, step: 0.5 }}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel id="answer-type-label">Answer type</InputLabel>
<Select
labelId="answer-type-label"
label="Answer type"
value={draft.answer_type}
onChange={(e) => updateDraft(selected.id, { answer_type: e.target.value as PartDraft['answer_type'] })}
>
{ANSWER_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel id="scheme-type-label">Scheme form</InputLabel>
<Select
labelId="scheme-type-label"
label="Scheme form"
value={draft.schemeType}
onChange={(e) => updateDraft(selected.id, { schemeType: e.target.value as MarkSchemeType })}
>
{SCHEME_TYPES.map((type) => <MenuItem key={type.value} value={type.value}>{type.label}</MenuItem>)}
</Select>
</FormControl>
</Grid>
</Grid>
<Autocomplete
options={specPoints}
value={selectedSpecPoint}
onChange={(_, value) => updateDraft(selected.id, { spec_ref: value?.ref ?? '' })}
getOptionLabel={(option) => `${option.ref}${option.description}`}
isOptionEqualToValue={(option, value) => option.ref === value.ref}
renderInput={(params) => (
<TextField
{...params}
label="SpecPoint picker"
helperText={specPoints.length ? 'Choose a seeded SpecPoint, or type below to override.' : 'No picker results loaded; type spec_ref manually below.'}
/>
)}
/>
<TextField
label="spec_ref"
value={draft.spec_ref}
onChange={(e) => 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
/>
<Divider />
<TextField
label={`${SCHEME_TYPES.find((type) => 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
/>
<TextField
label="Internal marking notes"
value={draft.notes}
onChange={(e) => updateDraft(selected.id, { notes: e.target.value })}
multiline
minRows={3}
fullWidth
/>
</Stack>
</CardContent>
</Card>
)}
</Grid>
</Grid>
)}
</Stack>
</Container>
);
};
export default MarkSchemePage;
-1
View File
@@ -1,2 +1 @@
export { default as ExamDashboardPage } from './ExamDashboardPage'; export { default as ExamDashboardPage } from './ExamDashboardPage';
export { default as MarkSchemePage } from './MarkSchemePage';
+117 -25
View File
@@ -8,14 +8,16 @@
import axios from 'axios'; import axios from 'axios';
import { API_BASE } from '../../config/apiConfig'; import { API_BASE } from '../../config/apiConfig';
import { logger } from '../../debugConfig';
import { supabase } from '../../supabaseClient'; import { supabase } from '../../supabaseClient';
import type { import type {
CreateTemplatePayload, CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
ExamResponseArea,
ExamTemplate, ExamTemplate,
ExamTemplateDetail, ExamTemplateDetail,
Neo4jSyncResult, UpdateTemplateMetaPayload,
PatchQuestionPayload,
SpecPoint,
} from '../../types/exam.types'; } from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`; const EXAM_BASE = `${API_BASE}/api/exam`;
@@ -28,6 +30,86 @@ async function authHeaders(): Promise<Record<string, string>> {
return { Authorization: `Bearer ${session.access_token}` }; return { Authorization: `Bearer ${session.access_token}` };
} }
function newUuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.floor(Math.random() * 16);
const v = c === 'x' ? r : (r % 4) + 8;
return v.toString(16);
});
}
function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
return {
id: idMap?.get(q.id) ?? q.id,
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
label: q.label,
order: q.order,
max_marks: q.max_marks,
answer_type: q.answer_type,
mcq_options: q.mcq_options,
mark_scheme: q.mark_scheme ?? {},
is_container: q.is_container,
spec_ref: q.spec_ref,
bounds: q.bounds ?? null,
page: q.page ?? null,
};
}
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : r.id,
question_id: idMap?.get(r.question_id) ?? r.question_id,
page: r.page,
bounds: r.bounds,
kind: r.kind,
response_form: r.response_form,
context_type: r.context_type ?? null,
source: r.source,
confirmed: r.confirmed,
confidence: r.confidence,
};
}
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : b.id,
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
label: b.label,
page_index: b.page_index,
y: b.y,
bounds: b.bounds,
source: b.source,
confirmed: b.confirmed,
};
}
async function replaceTemplate(
templateId: string,
detail: ExamTemplateDetail,
meta?: UpdateTemplateMetaPayload,
duplicateIds = false,
): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const idMap = new Map<string, string>();
if (duplicateIds) {
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
}
const res = await axios.put<ExamTemplateDetail>(
`${EXAM_BASE}/templates/${templateId}`,
{
meta,
questions: detail.questions.map((q) => questionPayload(q, idMap)),
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
},
{ headers },
);
return res.data;
}
export const examRepository = { export const examRepository = {
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> { async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
const headers = await authHeaders(); const headers = await authHeaders();
@@ -50,32 +132,42 @@ export const examRepository = {
return res.data; return res.data;
}, },
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
return res.data;
},
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
const detail = await this.getTemplate(templateId);
const created = await this.createTemplate({
title,
subject: detail.subject ?? undefined,
exam_id: detail.exam_id ?? undefined,
exam_code: detail.exam_code ?? undefined,
source_file_id: detail.source_file_id ?? undefined,
page_count: detail.page_count ?? undefined,
institute_id: detail.institute_id,
});
try {
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
} catch (error) {
try {
await this.archiveTemplate(created.id);
} catch (archiveError) {
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
templateId: created.id,
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
});
}
throw error;
}
},
async archiveTemplate(templateId: string): Promise<void> { async archiveTemplate(templateId: string): Promise<void> {
const headers = await authHeaders(); const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); 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<SpecPoint[]> {
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<Neo4jSyncResult> {
const headers = await authHeaders();
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
return res.data;
},
}; };
export default examRepository; export default examRepository;
+15 -61
View File
@@ -31,40 +31,11 @@ export interface CreateTemplatePayload {
institute_id?: string; institute_id?: string;
} }
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free'; export interface UpdateTemplateMetaPayload {
title?: string;
export interface MarkSchemePoint { subject?: string | null;
mark: number; page_count?: number | null;
text: string; status?: ExamTemplateStatus;
}
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). */ /** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
@@ -77,20 +48,28 @@ export interface ExamQuestion {
max_marks: number; max_marks: number;
answer_type: string | null; answer_type: string | null;
mcq_options: unknown | null; mcq_options: unknown | null;
mark_scheme: MarkScheme; mark_scheme: Record<string, unknown>;
is_container: boolean; is_container: boolean;
spec_ref: string | null; spec_ref: string | null;
bounds?: Record<string, number> | null; bounds?: Record<string, number> | null;
page?: number | null; page?: number | null;
} }
export type ExamResponseAreaKind =
| 'response'
| 'context'
| 'question_number'
| 'mark_area'
| 'reference'
| 'furniture';
export interface ExamResponseArea { export interface ExamResponseArea {
id: string; id: string;
question_id: string; question_id: string;
template_id: string; template_id: string;
page: number; page: number;
bounds: Record<string, number>; bounds: Record<string, number>;
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'; kind: ExamResponseAreaKind;
response_form: string | null; response_form: string | null;
context_type?: string | null; context_type?: string | null;
source: 'manual' | 'ai'; source: 'manual' | 'ai';
@@ -115,28 +94,3 @@ export interface ExamTemplateDetail extends ExamTemplate {
response_areas: ExamResponseArea[]; response_areas: ExamResponseArea[];
boundaries: ExamBoundary[]; 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<string, unknown>;
}