diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx
index 807db01..89a5c4d 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, 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 = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/pages/exam/CorpusPage.tsx b/src/pages/exam/CorpusPage.tsx
new file mode 100644
index 0000000..099b4cf
--- /dev/null
+++ b/src/pages/exam/CorpusPage.tsx
@@ -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 = { 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(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [board, setBoard] = useState(null);
+ const [level, setLevel] = useState(null);
+ const [subject, setSubject] = useState(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(
+ () => (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), [specs]);
+
+ return (
+
+ } onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>Exam Marker
+ Exam bank
+
+ The collected past-paper corpus — question papers, mark schemes and examiner reports per specification.
+
+
+ {error && setError(null)}>{error}}
+
+ {loading ? (
+
+ ) : (
+ <>
+ {/* Summary of the current selection */}
+
+
+
+
+
+
+
+
+ {/* Filters */}
+
+
+ setSciencesOnly((v) => !v)} />
+
+ Board:
+ setBoard(null)} />
+ {boards.map((b) => setBoard(b)} />)}
+
+
+ Level:
+ setLevel(null)} />
+ {levels.map((l) => setLevel(l)} />)}
+ {!sciencesOnly && (<>
+
+ Subject:
+ setSubject(null)} />
+ {subjects.map((s) => setSubject(s)} />)}
+ >)}
+
+
+
+ {specs.length === 0 ? (
+ No specifications match the current filters.
+ ) : specs.map((s) => (
+
+ }>
+
+
+ {s.level} {s.subject} · {s.spec_code}
+
+
+
+ {DOCS.map((d) => (
+
+ ))}
+
+
+
+
+
+
+
+ Session
+ Paper
+ Tier
+ {DOCS.map((d) => {DOC_LABEL[d]})}
+
+
+
+ {s.papers.map((p, i) => (
+
+ {p.session ?? '—'}
+ {p.paper_code ?? '—'}
+ {p.tier ?? '—'}
+ {DOCS.map((d) => (
+
+ {p.docs[d] ? : }
+
+ ))}
+
+ ))}
+
+
+
+
+
+ ))}
+ >
+ )}
+
+ );
+};
+
+export default CorpusPage;
diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx
index b81c819..170b779 100644
--- a/src/pages/exam/ExamDashboardPage.tsx
+++ b/src/pages/exam/ExamDashboardPage.tsx
@@ -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 = () => {
+ } onClick={() => navigate('/exam-marker/corpus')}>
+ Exam bank
+
} onClick={() => navigate('/exam-marker/bank')}>
Question bank
diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts
index 3489d49..94a95ab 100644
--- a/src/pages/exam/index.ts
+++ b/src/pages/exam/index.ts
@@ -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';
diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts
index 6c5daef..47fd69a 100644
--- a/src/services/exam/examRepository.ts
+++ b/src/services/exam/examRepository.ts
@@ -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 {
+ const headers = await authHeaders();
+ const res = await axios.get(`${EXAM_BASE}/corpus`, { headers });
+ return res.data;
+ },
+
async getBank(params: { specRef?: string; subject?: string } = {}): Promise {
const headers = await authHeaders();
const res = await axios.get(`${EXAM_BASE}/bank`, {
diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts
index 4c83a1e..42231ca 100644
--- a/src/types/exam.types.ts
+++ b/src/types/exam.types.ts
@@ -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>;
+ exam_codes: Partial>;
+}
+export interface CorpusSpec {
+ spec_code: string;
+ subject: string;
+ level: string;
+ board: string;
+ first_teach: string | null;
+ n_papers: number;
+ counts: Record;
+ 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[];
+}