Compare commits

..
Author SHA1 Message Date
kcar dbab592ce8 Merge P4 digital-text viewer
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-03 02:09:34 +00:00
kcarandClaude Opus 4.8 0c46582ec3 P4 app: Digital text viewer + extraction audit chip on setup page
'Digital text' button opens the digital-replica markdown (stem text, parts, marks,
answer-space placeholders, spec refs) in a dialog with download. A 'Marks N/M' chip
surfaces the extraction cover-total reconciliation (green=ok / amber=under-read) from
extraction_meta.audit. examRepository.getDigitalText + DigitalTextResponse type.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 02:09:34 +00:00
kcar e6ecc723cc Merge exam-canvas form badge
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 23:55:43 +00:00
kcarandClaude Opus 4.8 0f44dc614c Exam canvas: show recognised response FORM on each ghost
The extraction service types every answer space (multiple choice, draw-on, table,
ruled lines…), but the canvas rendered them all as a generic blue 'Response' box.
Add a small form badge (bottom-right) so the recognition is legible at a glance;
context regions show their type too. Colour-by-kind is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:55:43 +00:00
kcar 1bb2011e58 Merge exam-bank corpus coverage UI
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:35:35 +00:00
kcarandClaude Opus 4.8 f4be133069 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 <[email protected]>
2026-07-02 22:35:35 +00:00
kcar d47e0205c9 Merge mode-3 bank crop thumbnails
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:19:08 +00:00
kcarandClaude Opus 4.8 11d19778d6 Mode-3: crop thumbnails on question bank cards
Render a preview per bank question, client-side, from the source PDF cropped to
the question's bounds — makes the bank visually browsable like the spike cockpit.

bankCrops.ts: renders each template's PDF once (cached) via the existing pdf.js
loader (already at the 780px coordinate space the bounds live in), then crops the
page image to (bounds.x, bounds.y - pageTop, w, h) → a small PNG data-URL; decoded
page images are cached too. QuestionBankPage: a lazy <QuestionCrop> at the top of
each card (spinner → image, or "no preview" when a question has no bounds/PDF).

No API/infra change. tsc clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:19:08 +00:00
kcar a80bdea140 Merge mode-3 question bank UI
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:09:28 +00:00
kcarandClaude Opus 4.8 905626f494 Mode-3: Question bank page + custom-paper assembly UI
New /exam-marker/bank page: browse leaf questions across your papers, filter by
spec point (the spec-planning surface) and subject via facet chips, select
questions into a basket, and "Create paper" → POST /custom-papers → opens the new
template in setup. Dashboard gets a "Question bank" entry point.

examRepository.getBank + createCustomPaper; BankQuestion/BankResponse/custom-paper
types. tsc clean. Pairs with the mode-3 API endpoints (already merged).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:09:28 +00:00
kcar b52f6c54ba Merge fx-5-scan-ingestion
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 21:48:32 +00:00
kcar e6a2480818 Merge fx-8-response-area-attach 2026-07-02 21:48:32 +00:00
kcar 2b33aa80eb Merge fx-4-confirm-dismiss 2026-07-02 21:48:32 +00:00
CC WorkerandClaude Opus 4.8 7a1dd4dccc FX-5: scan-ingestion UI on the marking page
The marking flow read student_submissions but there was no way to create them —
print→scan→mark could not be completed in-app. Add an "Upload scans" control to
the marking queue that posts PDFs to the existing POST /batches/{id}/scans and
refreshes the queue.

- examRepository.uploadScan(batchId, file, {studentId?, matchingMethod?}): multipart
  upload (axios sets the boundary), returns the created/updated StudentSubmission.
- ExamMarkingPage: "Upload scans" button + hidden multi-file PDF input; uploads each
  with matching_method='ordered' (fills the roster in order; unmatched scans become
  reconcilable submissions), then reloads the queue with a success/failure summary.

No API or schema change (endpoint already existed). tsc clean; behaviour is covered
by the cc-browser-tester card (FX-V).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:44:04 +00:00
CC WorkerandClaude Opus 4.8 f72ea7189c FX-4: confirm/dismiss UI for AI ghost suggestions
The S5-7 review layer could surface unconfirmed AI shapes but had no way to
resolve them (confirmed was never set true), so the only "dismiss" was deletion
and the unconfirmed count never dropped. Add the resolution loop:

- A floating review-actions panel (shown while unconfirmed > 0): Accept selected,
  Reject (delete) selected, and Accept all AI on the page. Accept flips
  props.confirmed = true.
- Human edit = implicit confirm: nudging/resizing an AI ghost marks it confirmed
  (R3.3 — a teacher who adjusts a suggestion has reviewed it). Geometry-only
  trigger, so the confirmed-only update can't recurse.
- Confidence-driven opacity: unconfirmed ghosts fade by confidence instead of a
  constant 0.72; confirmed AI renders solid with its kind icon (like manual).
- Live review counts read straight off the canvas so the unconfirmed chip/panel
  update immediately on accept/reject (the old count reflected only saved state).

Persists through the existing PUT (confirmed already round-trips). No schema or
API change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:08:38 +00:00
11 changed files with 772 additions and 15 deletions
+3 -1
View File
@@ -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 } 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';
@@ -184,6 +184,8 @@ const AppRoutes: React.FC = () => {
<Route path="/search" element={<SearxngPage />} />
<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
View 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;
+10
View File
@@ -25,6 +25,8 @@ import AssignmentIcon from '@mui/icons-material/Assignment';
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';
@@ -233,9 +235,17 @@ const ExamDashboardPage: React.FC = () => {
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
</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>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
New template
</Button>
</Stack>
</Box>
{error && (
+49 -2
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { v5 as uuidv5 } from 'uuid';
import {
@@ -21,6 +21,7 @@ import {
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import TableChartIcon from '@mui/icons-material/TableChart';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { examRepository } from '../../services/exam/examRepository';
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
@@ -43,6 +44,8 @@ const ExamMarkingPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const load = useCallback(async () => {
if (!batchId) return;
@@ -68,6 +71,28 @@ const ExamMarkingPage: React.FC = () => {
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
[template],
);
const onScanFiles = useCallback(async (files: FileList | null) => {
if (!batchId || !files || files.length === 0) return;
setUploading(true); setError(null); setMessage(null);
let ok = 0;
const failures: string[] = [];
for (const file of Array.from(files)) {
try {
// 'ordered' assigns each scan to the next not-yet-submitted roster slot; unmatched scans
// become an unmatched submission the teacher can reconcile.
await examRepository.uploadScan(batchId, file, { matchingMethod: 'ordered' });
ok += 1;
} catch (e) {
failures.push(`${file.name}: ${e instanceof Error ? e.message : String(e)}`);
}
}
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
await load();
if (ok) setMessage(`Uploaded ${ok} scan${ok === 1 ? '' : 's'}${failures.length ? `, ${failures.length} failed` : ''}`);
if (failures.length) setError(failures.join(' · '));
}, [batchId, load]);
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
const saveSelected = async () => {
@@ -154,7 +179,29 @@ const ExamMarkingPage: React.FC = () => {
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
<CardContent>
<Typography variant="h6" gutterBottom>Marking queue</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
<Typography variant="h6">Marking queue</Typography>
<Button
size="small"
variant="outlined"
startIcon={<UploadFileIcon fontSize="small" />}
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? 'Uploading…' : 'Upload scans'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
multiple
hidden
onChange={(e) => { void onScanFiles(e.target.files); }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
+207
View File
@@ -0,0 +1,207 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert, Box, Button, Card, CardActionArea, CardContent, Chip, CircularProgress, Container,
Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
import { examRepository } from '../../services/exam/examRepository';
import type { BankQuestion, BankResponse } from '../../types/exam.types';
import { cropQuestion } from './bankCrops';
const QuestionCrop: React.FC<{ q: BankQuestion }> = ({ q }) => {
const [src, setSrc] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
let alive = true;
cropQuestion({ templateId: q.template_id, page: q.page, bounds: q.bounds })
.then((d) => { if (alive) { setSrc(d); setDone(true); } })
.catch(() => { if (alive) setDone(true); });
return () => { alive = false; };
}, [q.template_id, q.page, q.bounds]);
if (src) {
return <Box component="img" src={src} alt="" loading="lazy" sx={{ width: '100%', maxHeight: 150, objectFit: 'cover', objectPosition: 'top', display: 'block', bgcolor: '#fff', borderBottom: 1, borderColor: 'divider' }} />;
}
return (
<Box sx={{ height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: 1, borderColor: 'divider', color: 'text.disabled', fontSize: 12 }}>
{done ? 'no preview' : <CircularProgress size={16} />}
</Box>
);
};
const QuestionBankPage: React.FC = () => {
const navigate = useNavigate();
const [bank, setBank] = useState<BankResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [specRef, setSpecRef] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
const [title, setTitle] = useState('');
const [creating, setCreating] = useState(false);
const load = useCallback(async () => {
setLoading(true); setError(null);
try {
setBank(await examRepository.getBank({ specRef: specRef ?? undefined, subject: subject ?? undefined }));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [specRef, subject]);
useEffect(() => { void load(); }, [load]);
const toggle = (id: string) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const selectedList = useMemo(() => Array.from(selected), [selected]);
const selectedQuestions = useMemo(
() => (bank?.questions ?? []).filter((q) => selected.has(q.id)),
[bank, selected],
);
const totalMarks = selectedQuestions.reduce((s, q) => s + (q.max_marks ?? 0), 0);
// facet lists (sorted) with counts
const specFacets = Object.entries(bank?.facets.spec_ref ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
const subjectFacets = Object.entries(bank?.facets.subject ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
const create = async () => {
setCreating(true); setError(null);
try {
const subj = subject ?? selectedQuestions[0]?.paper.subject ?? undefined;
const res = await examRepository.createCustomPaper({
title: title.trim() || `Custom paper — ${new Date().toISOString().slice(0, 10)}`,
subject: subj ?? undefined,
question_ids: selectedList,
});
navigate(`/exam-marker/${res.id}/setup`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setCreating(false);
setDialogOpen(false);
}
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Exam Marker
</Button>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<Box>
<Typography variant="h4" component="h1" fontWeight={700}>Question bank</Typography>
<Typography variant="body2" color="text.secondary">
Plan by spec point, then assemble a custom paper from questions across your papers.
</Typography>
</Box>
<Button
variant="contained"
startIcon={<PlaylistAddCheckIcon />}
disabled={selected.size === 0}
onClick={() => setDialogOpen(true)}
>
Create paper ({selected.size}{totalMarks ? ` · ${totalMarks}m` : ''})
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{/* Filters */}
<Stack spacing={1} sx={{ mb: 2 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Spec point:</Typography>
<Chip size="small" label="all" color={specRef ? 'default' : 'primary'} onClick={() => setSpecRef(null)} variant={specRef ? 'outlined' : 'filled'} />
{specFacets.map(([ref, count]) => (
<Chip key={ref} size="small" label={`${ref} · ${count}`} color={specRef === ref ? 'primary' : 'default'} variant={specRef === ref ? 'filled' : 'outlined'} onClick={() => setSpecRef(ref)} />
))}
</Stack>
{subjectFacets.length > 1 && (
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Subject:</Typography>
<Chip size="small" label="all" color={subject ? 'default' : 'secondary'} onClick={() => setSubject(null)} variant={subject ? 'outlined' : 'filled'} />
{subjectFacets.map(([s, count]) => (
<Chip key={s} size="small" label={`${s} · ${count}`} color={subject === s ? 'secondary' : 'default'} variant={subject === s ? 'filled' : 'outlined'} onClick={() => setSubject(s)} sx={{ textTransform: 'capitalize' }} />
))}
</Stack>
)}
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : (bank?.questions.length ?? 0) === 0 ? (
<Alert severity="info">
No questions match yet. Questions appear here once your templates have parts with spec points
(set them in the mark-scheme editor). Auto-map + confirm on a paper to populate it.
</Alert>
) : (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{bank?.n} question{bank?.n === 1 ? '' : 's'} · click to add to your paper
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, gap: 1.5 }}>
{(bank?.questions ?? []).map((q: BankQuestion) => {
const on = selected.has(q.id);
return (
<Card key={q.id} variant="outlined" sx={{ borderColor: on ? 'primary.main' : 'divider', borderWidth: on ? 2 : 1 }}>
<CardActionArea onClick={() => toggle(q.id)}>
<QuestionCrop q={q} />
<CardContent sx={{ pb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" fontWeight={700}>Q{q.label ?? '?'}</Typography>
{on ? <CheckCircleIcon color="primary" fontSize="small" /> : <RadioButtonUncheckedIcon color="disabled" fontSize="small" />}
</Box>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 0.75 }}>
<Chip size="small" label={`${q.max_marks ?? 0} marks`} />
{q.answer_type && <Chip size="small" variant="outlined" label={q.answer_type} />}
{q.spec_ref && <Chip size="small" color="info" label={q.spec_ref} />}
</Stack>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block', textTransform: 'capitalize' }}>
{[q.paper.subject, q.paper.title].filter(Boolean).join(' · ') || 'Untitled paper'}
</Typography>
</CardContent>
</CardActionArea>
</Card>
);
})}
</Box>
</>
)}
<Dialog open={dialogOpen} onClose={() => !creating && setDialogOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Create custom paper</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{selected.size} question{selected.size === 1 ? '' : 's'} · {totalMarks} marks. This creates a new
template you can open in setup and use for marking.
</Typography>
<TextField
autoFocus
fullWidth
label="Paper title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={`Custom paper — ${new Date().toISOString().slice(0, 10)}`}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)} disabled={creating}>Cancel</Button>
<Button variant="contained" onClick={create} disabled={creating} startIcon={creating ? <CircularProgress size={16} color="inherit" /> : undefined}>
{creating ? 'Creating…' : 'Create & open'}
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default QuestionBankPage;
+68
View File
@@ -0,0 +1,68 @@
// Client-side crop thumbnails for the question bank. A bank question carries `bounds` (x,y,w,h) in the
// 780px stacked-page canvas space and a `page` number; the source PDF renders at that same 780px width
// (pdfLoader), so a thumbnail is just the page image cropped to the bounds. Renders each template's PDF
// once (cached) and reuses it for all of that template's questions. No API/infra change.
import { examRepository } from '../../services/exam/examRepository';
import { loadPdfPageImages, PdfPageImage } from './setup/pdfLoader';
const renderCache = new Map<string, Promise<PdfPageImage[]>>();
const imageCache = new Map<string, Promise<HTMLImageElement>>();
function templateRender(templateId: string): Promise<PdfPageImage[]> {
let p = renderCache.get(templateId);
if (!p) {
p = examRepository.getTemplateSourcePdf(templateId).then((bytes) => loadPdfPageImages(bytes));
p.catch(() => renderCache.delete(templateId)); // let a failed render be retried
renderCache.set(templateId, p);
}
return p;
}
function loadImage(src: string): Promise<HTMLImageElement> {
let p = imageCache.get(src);
if (!p) {
p = new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('image decode failed'));
img.src = src;
});
imageCache.set(src, p);
}
return p;
}
export interface CropInput {
templateId: string;
page: number | null;
bounds: Record<string, number> | null;
}
// Returns a PNG data-URL cropped to the question, or null if it can't be produced (no bounds/page/PDF).
export async function cropQuestion(input: CropInput, maxWidth = 320): Promise<string | null> {
const { templateId, page, bounds } = input;
if (!bounds || !page) return null;
const pages = await templateRender(templateId);
const pageImg = pages.find((pg) => pg.pageNumber === page);
if (!pageImg) return null;
// stacked y-offset of this page (bounds.y is absolute across the vertically-stacked pages)
let yTop = 0;
for (const pg of pages) { if (pg.pageNumber === page) break; yTop += pg.height; }
const sx = Math.max(0, Math.round(bounds.x ?? 0));
const sy = Math.max(0, Math.round((bounds.y ?? 0) - yTop));
const sw = Math.min(Math.round(bounds.w ?? pageImg.width), pageImg.width - sx);
const sh = Math.min(Math.round(bounds.h ?? 80), pageImg.height - sy);
if (sw <= 1 || sh <= 1) return null;
const img = await loadImage(pageImg.src);
const scale = Math.min(1, maxWidth / sw);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(sw * scale));
canvas.height = Math.max(1, Math.round(sh * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
}
+2
View File
@@ -1,4 +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';
+132 -4
View File
@@ -1,12 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Dialog, DialogContent, DialogTitle, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
import DescriptionIcon from '@mui/icons-material/Description'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
import SaveIcon from '@mui/icons-material/Save'
import MouseIcon from '@mui/icons-material/Mouse'
import CheckIcon from '@mui/icons-material/Check'
import CloseIcon from '@mui/icons-material/Close'
import DoneAllIcon from '@mui/icons-material/DoneAll'
import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
import axios from 'axios'
@@ -14,7 +18,7 @@ import axios from 'axios'
import { ErrorBoundary } from '../../../components/ErrorBoundary'
import { logger } from '../../../debugConfig'
import { examRepository } from '../../../services/exam/examRepository'
import type { AutoMapJobStatus, ExamTemplateDetail } from '../../../types/exam.types'
import type { AutoMapJobStatus, DigitalTextResponse, ExamTemplateDetail } from '../../../types/exam.types'
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
@@ -82,6 +86,21 @@ function reviewSummary(template: ExamTemplateDetail | null) {
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
}
// Live review counts read straight off the canvas shapes, so accept/reject update them immediately
// (the template-derived reviewSummary only reflects the last *saved* state).
function reviewFromShapes(editor: Editor | null) {
const acc = { ai: 0, unconfirmed: 0, lowConfidence: 0 }
if (!editor) return acc
for (const shape of editor.getCurrentPageShapes()) {
if (!shapeTypeToKind(shape.type)) continue
const p = (shape as ExamCanvasTLShape).props
if (p.source === 'ai') acc.ai += 1
if (p.source === 'ai' && p.confirmed === false) acc.unconfirmed += 1
if (typeof p.confidence === 'number' && p.confidence < 0.7) acc.lowConfidence += 1
}
return acc
}
function stripShapePrefix(id: string) {
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
}
@@ -205,8 +224,28 @@ const ExamTemplateSetupInner: React.FC = () => {
const [guideOpen, setGuideOpen] = useState(false)
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
const [autoMapBusy, setAutoMapBusy] = useState(false)
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
const [digitalText, setDigitalText] = useState<DigitalTextResponse | null>(null)
const [digitalOpen, setDigitalOpen] = useState(false)
const [digitalBusy, setDigitalBusy] = useState(false)
const autoMapPollRef = useRef<number | null>(null)
const openDigitalText = useCallback(async () => {
if (!template) return
setDigitalBusy(true); setDigitalOpen(true)
try {
setDigitalText(await examRepository.getDigitalText(template.id))
} catch (e) {
setDigitalText(null)
setError(e instanceof Error ? e.message : 'No digital text yet — run auto-map first.')
setDigitalOpen(false)
} finally {
setDigitalBusy(false)
}
}, [template])
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
setTemplate(detail)
const editor = editorRef.current
@@ -219,7 +258,33 @@ const ExamTemplateSetupInner: React.FC = () => {
setDirty(false)
}, [])
const review = useMemo(() => reviewSummary(template), [template])
const acceptShapes = useCallback((shapes: TLShape[]) => {
const editor = editorRef.current
if (!editor) return
const updates = shapes
.filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false)
.map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } }))
if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) }
}, [refreshReview])
const selectedDomainShapes = useCallback((): TLShape[] => {
const editor = editorRef.current
if (!editor) return []
return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type))
}, [])
const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes])
const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes])
const rejectSelected = useCallback(() => {
const editor = editorRef.current
if (!editor) return
const ids = selectedDomainShapes().map((s) => s.id)
if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) }
}, [refreshReview, selectedDomainShapes])
// Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the
// saved-template summary only for the brief moment before shapes have mounted).
const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template)
const load = useCallback(async () => {
if (!templateId) return
@@ -392,6 +457,11 @@ const ExamTemplateSetupInner: React.FC = () => {
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
{(() => {
const a = (template as (ExamTemplateDetail & { extraction_meta?: { audit?: { status?: string; cover_total?: number; detected_total?: number } } }) | null)?.extraction_meta?.audit
return a?.status ? <Tooltip title="Extraction cover-total reconciliation (detected vs printed maximum)"><Chip size="small" color={a.status === 'ok' ? 'success' : 'warning'} label={`Marks ${a.detected_total ?? '?'}/${a.cover_total ?? '?'}`} /></Tooltip> : null
})()}
<Button size="small" variant="outlined" startIcon={digitalBusy ? <CircularProgress size={14} /> : <DescriptionIcon fontSize="small" />} onClick={openDigitalText} disabled={digitalBusy || !template}>Digital text</Button>
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
</Paper>
@@ -416,7 +486,23 @@ const ExamTemplateSetupInner: React.FC = () => {
onMount={(editor) => {
editorRef.current = editor
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
editor.store.listen(() => setDirty(true), { scope: 'document' })
editor.store.listen(() => { setDirty(true); refreshReview() }, { scope: 'document' })
// Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who
// adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only
// update below can't recurse.
editor.store.listen((entry) => {
try {
const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = []
for (const rec of Object.values(entry.changes.updated)) {
const [from, to] = rec as [any, any]
if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue
if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue
const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h
if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } })
}
if (updates.length) editor.updateShapes(updates as any)
} catch { /* non-fatal: implicit-confirm is best-effort */ }
}, { scope: 'document', source: 'user' })
applyDocViewConstraints(editor, [])
editor.resetZoom()
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
@@ -432,6 +518,22 @@ const ExamTemplateSetupInner: React.FC = () => {
/>
</Box>
{/* AI review actions — appears while there are unconfirmed suggestions to resolve */}
{review.unconfirmed > 0 && (
<Paper elevation={6} sx={{ position: 'absolute', left: 16, bottom: 16, zIndex: 1002, px: 1, py: 0.75, borderRadius: 2, bgcolor: 'background.paper', display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip size="small" color="warning" label={`${review.unconfirmed} AI unconfirmed`} sx={{ fontWeight: 700 }} />
<Tooltip title="Accept the selected AI suggestions (nudging one also confirms it)">
<span><Button size="small" color="success" startIcon={<CheckIcon fontSize="small" />} onClick={acceptSelected}>Accept</Button></span>
</Tooltip>
<Tooltip title="Reject (delete) the selected shapes">
<span><Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} onClick={rejectSelected}>Reject</Button></span>
</Tooltip>
<Tooltip title="Accept every AI suggestion on the page">
<span><Button size="small" variant="contained" startIcon={<DoneAllIcon fontSize="small" />} onClick={acceptAllAi}>Accept all</Button></span>
</Tooltip>
</Paper>
)}
{/* Guide toggle */}
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
@@ -473,6 +575,32 @@ const ExamTemplateSetupInner: React.FC = () => {
</Box>
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
<Dialog open={digitalOpen} onClose={() => setDigitalOpen(false)} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionIcon fontSize="small" />
Digital text
{digitalText && <Typography component="span" variant="body2" color="text.secondary"> {digitalText.title} · {digitalText.n_questions}Q · {digitalText.total_marks} marks</Typography>}
<Box sx={{ flex: 1 }} />
{digitalText && (
<Tooltip title="Download markdown">
<IconButton size="small" onClick={() => {
const blob = new Blob([digitalText.markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob); const a = document.createElement('a')
a.href = url; a.download = `${digitalText.slug}.md`; a.click(); URL.revokeObjectURL(url)
}}><SaveIcon fontSize="small" /></IconButton>
</Tooltip>
)}
</DialogTitle>
<DialogContent dividers>
{digitalBusy ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : digitalText ? (
<Box component="pre" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 13, m: 0 }}>{digitalText.markdown}</Box>
) : (
<Typography variant="body2" color="text.secondary">No digital text available.</Typography>
)}
</DialogContent>
</Dialog>
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
</Box>
)
+26 -5
View File
@@ -66,6 +66,18 @@ export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry>
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
}
// Human-legible names for the extraction service's response forms — so the canvas SHOWS what was
// recognised (multiple-choice, draw-on, table…), not just a generic "Response" box.
const RESPONSE_FORM_LABEL: Record<string, string> = {
lines: 'ruled lines',
'answer-box': 'answer box',
working: 'working space',
diagram: 'draw-on',
'tick-boxes': 'multiple choice',
table: 'table',
blanks: 'fill-in',
}
const shapeCss = `
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
@@ -81,6 +93,12 @@ function confidenceLabel(confidence: number | null | undefined) {
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
}
// Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid.
function ghostOpacity(confidence: number | null | undefined) {
if (typeof confidence !== 'number') return 0.72
return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence))
}
function reviewFlags(shape: ExamCanvasTLShape): string[] {
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
}
@@ -100,16 +118,17 @@ function renderBoundaryLine(shape: ExamCanvasTLShape) {
const p = canvasShapePalette.boundary
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
const isAi = shape.props.source === 'ai'
const isGhost = isAi && shape.props.confirmed === false
const confidence = confidenceLabel(shape.props.confidence)
const flags = reviewFlags(shape)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
<style>{shapeCss}</style>
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isAi ? '4 6' : p.dash} strokeLinecap="round" opacity={isAi ? 0.62 : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
</svg>
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
<span aria-hidden="true">{isGhost ? 'AI' : p.icon}</span>
{shape.props.label || p.label}
</span>
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
@@ -139,8 +158,8 @@ function renderShape(shape: ExamCanvasTLShape) {
'--exam-dark-stroke': p.darkStroke,
'--exam-dark-fill': p.darkFill,
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
borderStyle: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
} as React.CSSProperties}
@@ -148,12 +167,14 @@ function renderShape(shape: ExamCanvasTLShape) {
title={title}
>
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
<span aria-hidden="true">{isAiSuggestion ? 'AI' : p.icon}</span>
{shape.props.label || p.label}
</span>
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
{!confidence && !isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
{flags.length > 0 && <span className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px', maxWidth: 'calc(100% - 16px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{flags.slice(0, 2).join(' · ')}</span>}
{kind === 'response' && shape.props.responseForm && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.9 }}>{RESPONSE_FORM_LABEL[shape.props.responseForm] ?? shape.props.responseForm}</span>}
{kind === 'context' && shape.props.contextType && shape.props.contextType !== 'generic' && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.9 }}>{shape.props.contextType}</span>}
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
</div>
</HTMLContainer>
+48
View File
@@ -13,9 +13,14 @@ import { supabase } from '../../supabaseClient';
import type {
AutoMapJobStatus,
AutoMapResponse,
BankResponse,
CorpusResponse,
DigitalTextResponse,
BatchQueueResponse,
BatchResultsResponse,
CreateBatchPayload,
CreateCustomPaperPayload,
CreateCustomPaperResult,
CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
@@ -28,6 +33,7 @@ import type {
Neo4jSyncResult,
PatchQuestionPayload,
SpecPoint,
StudentSubmission,
TemplateReplacePayload,
UpdateTemplateMetaPayload,
} from '../../types/exam.types';
@@ -159,6 +165,12 @@ export const examRepository = {
return res.data.templates ?? [];
},
async getDigitalText(templateId: string): Promise<DigitalTextResponse> {
const headers = await authHeaders();
const res = await axios.get<DigitalTextResponse>(`${EXAM_BASE}/templates/${templateId}/digital-text`, { headers });
return res.data;
},
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
@@ -298,6 +310,42 @@ export const examRepository = {
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
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`, {
headers,
params: { spec_ref: params.specRef, subject: params.subject },
});
return res.data;
},
async createCustomPaper(payload: CreateCustomPaperPayload): Promise<CreateCustomPaperResult> {
const headers = await authHeaders();
const res = await axios.post<CreateCustomPaperResult>(`${EXAM_BASE}/custom-papers`, payload, { headers });
return res.data;
},
async uploadScan(
batchId: string,
file: File,
opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {},
): Promise<StudentSubmission> {
const headers = await authHeaders();
const form = new FormData();
form.append('file', file);
form.append('matching_method', opts.matchingMethod ?? 'ordered');
if (opts.studentId) form.append('student_id', opts.studentId);
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
return res.data;
},
};
export default examRepository;
+68
View File
@@ -338,3 +338,71 @@ export interface MarkUpsertPayload {
comment?: string;
confirmed?: boolean;
}
// ── Mode-3 question bank + custom-paper assembly ──────────────────────────────
export interface BankQuestion {
id: string;
template_id: string;
label: string | null;
max_marks: number | null;
answer_type: string | null;
spec_ref: string | null;
bounds: Record<string, number> | null;
page: number | null;
paper: { id: string | null; title: string | null; subject: string | null; exam_code: string | null };
}
export interface BankResponse {
questions: BankQuestion[];
n: number;
facets: { spec_ref: Record<string, number>; subject: Record<string, number> };
}
export interface CreateCustomPaperPayload {
title: string;
subject?: string;
question_ids: string[];
}
export interface CreateCustomPaperResult {
id: string;
title: string;
subject: string | null;
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[];
}
// ── Digital-replica markdown (P4) ─────────────────────────────────────────────
export interface DigitalTextResponse {
slug: string;
title: string;
n_questions: number;
total_marks: number;
markdown: string;
questions: { label: string; marks: number | null; markdown: string }[];
}