Exam bank: corpus coverage dashboard (/exam-marker/corpus)
New "Exam bank" page displaying the state of the collected past-paper corpus: per-specification coverage of question papers / mark schemes / examiner reports, with global + filtered rollups, board/level/subject filters, a "sciences only" default, and an expandable per-session table (QP/MS/ER present ✓/–). Reads GET /api/exam/corpus. Dashboard gets an "Exam bank" entry point. examRepository.getCorpus + corpus types. tsc clean on the changed files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d47e0205c9
commit
f4be133069
@ -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, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage, QuestionBankPage } from './pages/exam';
|
||||
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage, QuestionBankPage, CorpusPage } from './pages/exam';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import CalendarPage from './pages/user/calendarPage';
|
||||
import SettingsPage from './pages/user/settingsPage';
|
||||
@ -185,6 +185,7 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/bank" element={<ErrorBoundary><QuestionBankPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/corpus" element={<ErrorBoundary><CorpusPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
|
||||
|
||||
156
src/pages/exam/CorpusPage.tsx
Normal file
156
src/pages/exam/CorpusPage.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, CircularProgress, Container,
|
||||
Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
import type { CorpusResponse, CorpusSpec, CorpusDocType } from '../../types/exam.types';
|
||||
|
||||
const DOCS: CorpusDocType[] = ['QP', 'MS', 'ER'];
|
||||
const DOC_LABEL: Record<CorpusDocType, string> = { QP: 'Question papers', MS: 'Mark schemes', ER: 'Examiner reports' };
|
||||
const SCIENCES = new Set(['Biology', 'Chemistry', 'Physics']);
|
||||
|
||||
const CorpusPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [corpus, setCorpus] = useState<CorpusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [board, setBoard] = useState<string | null>(null);
|
||||
const [level, setLevel] = useState<string | null>(null);
|
||||
const [subject, setSubject] = useState<string | null>(null);
|
||||
const [sciencesOnly, setSciencesOnly] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try { setCorpus(await examRepository.getCorpus()); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const allSpecs = useMemo<CorpusSpec[]>(
|
||||
() => (corpus?.boards ?? []).flatMap((b) => b.specs),
|
||||
[corpus],
|
||||
);
|
||||
const boards = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.board))).sort(), [allSpecs]);
|
||||
const levels = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.level))).sort(), [allSpecs]);
|
||||
const subjects = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.subject))).sort(), [allSpecs]);
|
||||
|
||||
const specs = useMemo(() => allSpecs.filter((s) =>
|
||||
(!board || s.board === board) && (!level || s.level === level) && (!subject || s.subject === subject)
|
||||
&& (!sciencesOnly || SCIENCES.has(s.subject))
|
||||
).sort((a, b) => (a.board + a.level + a.subject + a.spec_code).localeCompare(b.board + b.level + b.subject + b.spec_code)),
|
||||
[allSpecs, board, level, subject, sciencesOnly]);
|
||||
|
||||
const filteredTotals = useMemo(() => specs.reduce((acc, s) => {
|
||||
acc.specs += 1; acc.papers += s.n_papers;
|
||||
DOCS.forEach((d) => { acc[d] += s.counts[d] ?? 0; });
|
||||
return acc;
|
||||
}, { specs: 0, papers: 0, QP: 0, MS: 0, ER: 0 } as Record<string, number>), [specs]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>Exam Marker</Button>
|
||||
<Typography variant="h4" component="h1" fontWeight={700}>Exam bank</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
The collected past-paper corpus — question papers, mark schemes and examiner reports per specification.
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary of the current selection */}
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
|
||||
<Chip color="default" label={`${filteredTotals.specs} specifications`} />
|
||||
<Chip color="default" label={`${filteredTotals.papers} papers`} />
|
||||
<Chip color="primary" variant="outlined" label={`${filteredTotals.QP} question papers`} />
|
||||
<Chip color="success" variant="outlined" label={`${filteredTotals.MS} mark schemes`} />
|
||||
<Chip color="warning" variant="outlined" label={`${filteredTotals.ER} examiner reports`} />
|
||||
</Stack>
|
||||
|
||||
{/* Filters */}
|
||||
<Stack spacing={0.75} sx={{ mb: 2 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Chip size="small" color={sciencesOnly ? 'secondary' : 'default'} variant={sciencesOnly ? 'filled' : 'outlined'} label="Sciences only" onClick={() => setSciencesOnly((v) => !v)} />
|
||||
<Box sx={{ width: 12 }} />
|
||||
<Typography variant="caption" color="text.secondary">Board:</Typography>
|
||||
<Chip size="small" label="all" variant={board ? 'outlined' : 'filled'} color={board ? 'default' : 'primary'} onClick={() => setBoard(null)} />
|
||||
{boards.map((b) => <Chip key={b} size="small" label={b} variant={board === b ? 'filled' : 'outlined'} color={board === b ? 'primary' : 'default'} onClick={() => setBoard(b)} />)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="caption" color="text.secondary">Level:</Typography>
|
||||
<Chip size="small" label="all" variant={level ? 'outlined' : 'filled'} color={level ? 'default' : 'primary'} onClick={() => setLevel(null)} />
|
||||
{levels.map((l) => <Chip key={l} size="small" label={l} variant={level === l ? 'filled' : 'outlined'} color={level === l ? 'primary' : 'default'} onClick={() => setLevel(l)} />)}
|
||||
{!sciencesOnly && (<>
|
||||
<Box sx={{ width: 12 }} />
|
||||
<Typography variant="caption" color="text.secondary">Subject:</Typography>
|
||||
<Chip size="small" label="all" variant={subject ? 'outlined' : 'filled'} color={subject ? 'default' : 'primary'} onClick={() => setSubject(null)} />
|
||||
{subjects.map((s) => <Chip key={s} size="small" label={s} variant={subject === s ? 'filled' : 'outlined'} color={subject === s ? 'primary' : 'default'} onClick={() => setSubject(s)} />)}
|
||||
</>)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{specs.length === 0 ? (
|
||||
<Alert severity="info">No specifications match the current filters.</Alert>
|
||||
) : specs.map((s) => (
|
||||
<Accordion key={s.spec_code} disableGutters>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', width: '100%' }}>
|
||||
<Typography sx={{ fontWeight: 700, minWidth: 260 }}>
|
||||
{s.level} {s.subject} <Typography component="span" color="text.secondary" variant="body2">· {s.spec_code}</Typography>
|
||||
</Typography>
|
||||
<Chip size="small" label={`${s.n_papers} papers`} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{DOCS.map((d) => (
|
||||
<Chip key={d} size="small" variant="outlined"
|
||||
color={s.counts[d] ? (d === 'QP' ? 'primary' : d === 'MS' ? 'success' : 'warning') : 'default'}
|
||||
label={`${d} ${s.counts[d] ?? 0}`} />
|
||||
))}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 0 }}>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Session</TableCell>
|
||||
<TableCell>Paper</TableCell>
|
||||
<TableCell>Tier</TableCell>
|
||||
{DOCS.map((d) => <TableCell key={d} align="center">{DOC_LABEL[d]}</TableCell>)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{s.papers.map((p, i) => (
|
||||
<TableRow key={`${p.paper_code}-${p.session}-${i}`} hover>
|
||||
<TableCell>{p.session ?? '—'}</TableCell>
|
||||
<TableCell>{p.paper_code ?? '—'}</TableCell>
|
||||
<TableCell>{p.tier ?? '—'}</TableCell>
|
||||
{DOCS.map((d) => (
|
||||
<TableCell key={d} align="center">
|
||||
{p.docs[d] ? <CheckIcon fontSize="small" color="success" /> : <RemoveIcon fontSize="small" color="disabled" />}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default CorpusPage;
|
||||
@ -26,6 +26,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import GradingIcon from '@mui/icons-material/Grading';
|
||||
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
@ -235,6 +236,9 @@ const ExamDashboardPage: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="outlined" startIcon={<LibraryBooksIcon />} onClick={() => navigate('/exam-marker/corpus')}>
|
||||
Exam bank
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<PlaylistAddCheckIcon />} onClick={() => navigate('/exam-marker/bank')}>
|
||||
Question bank
|
||||
</Button>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||
export { default as QuestionBankPage } from './QuestionBankPage';
|
||||
export { default as CorpusPage } from './CorpusPage';
|
||||
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||
export { default as MarkSchemePage } from './MarkSchemePage';
|
||||
export { default as ExamMarkingPage } from './ExamMarkingPage';
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
AutoMapJobStatus,
|
||||
AutoMapResponse,
|
||||
BankResponse,
|
||||
CorpusResponse,
|
||||
BatchQueueResponse,
|
||||
BatchResultsResponse,
|
||||
CreateBatchPayload,
|
||||
@ -303,6 +304,12 @@ export const examRepository = {
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getCorpus(): Promise<CorpusResponse> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<CorpusResponse>(`${EXAM_BASE}/corpus`, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getBank(params: { specRef?: string; subject?: string } = {}): Promise<BankResponse> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<BankResponse>(`${EXAM_BASE}/bank`, {
|
||||
|
||||
@ -371,3 +371,28 @@ export interface CreateCustomPaperResult {
|
||||
n_questions: number;
|
||||
n_response_areas: number;
|
||||
}
|
||||
|
||||
// ── Exam-bank corpus coverage (state of the collected bank) ───────────────────
|
||||
export type CorpusDocType = 'QP' | 'MS' | 'ER';
|
||||
export interface CorpusPaper {
|
||||
paper_code: string | null;
|
||||
session: string | null;
|
||||
tier: string | null;
|
||||
docs: Partial<Record<CorpusDocType, boolean>>;
|
||||
exam_codes: Partial<Record<CorpusDocType, string>>;
|
||||
}
|
||||
export interface CorpusSpec {
|
||||
spec_code: string;
|
||||
subject: string;
|
||||
level: string;
|
||||
board: string;
|
||||
first_teach: string | null;
|
||||
n_papers: number;
|
||||
counts: Record<CorpusDocType, number>;
|
||||
papers: CorpusPaper[];
|
||||
}
|
||||
export interface CorpusBoard { board: string; n_specs: number; specs: CorpusSpec[] }
|
||||
export interface CorpusResponse {
|
||||
totals: { specs: number; papers: number; sessions: number; QP: number; MS: number; ER: number };
|
||||
boards: CorpusBoard[];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user