Compare commits
No commits in common. "master" and "agent/s4-11-browser-api-base-fix" have entirely different histories.
master
...
agent/s4-1
@ -67,15 +67,6 @@ RUN echo 'server { \
|
||||
proxy_set_header X-Real-IP $remote_addr; \
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||
} \
|
||||
location /__supabase/ { \
|
||||
proxy_pass http://192.168.0.94:8000/; \
|
||||
proxy_set_header Host $host; \
|
||||
proxy_set_header X-Real-IP $remote_addr; \
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||
proxy_set_header Upgrade $http_upgrade; \
|
||||
proxy_set_header Connection "upgrade"; \
|
||||
proxy_http_version 1.1; \
|
||||
} \
|
||||
location /api/ { \
|
||||
proxy_pass http://192.168.0.64:18000/api/; \
|
||||
proxy_set_header Host $host; \
|
||||
|
||||
@ -20,21 +20,16 @@ services:
|
||||
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
|
||||
# strips that prefix before forwarding, preserving mixed backend routes
|
||||
# such as /api/exam, /me/bootstrap, and /database/timetable.
|
||||
# Supabase is likewise routed same-origin via Dockerfile's /__supabase
|
||||
# proxy -> dev Supabase .94 (no CORS, browser-network-agnostic). The
|
||||
# leading-slash URL is resolved against window.location.origin in
|
||||
# supabaseClient.ts. Provide the matching .94 anon key as a build arg
|
||||
# (VITE_SUPABASE_ANON_KEY) so it pairs with the .94 backend.
|
||||
# .env.dev still points at the LAN API/Supabase for local Vite/dev tooling.
|
||||
# .env.dev still points at the LAN API for local Vite/dev tooling.
|
||||
VITE_API_BASE: /__ccapi
|
||||
VITE_API_URL: /__ccapi
|
||||
VITE_SUPABASE_URL: /__supabase
|
||||
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
|
||||
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
|
||||
VITE_DEV: ${VITE_DEV:-false}
|
||||
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
|
||||
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
|
||||
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
|
||||
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL:-}
|
||||
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
|
||||
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
|
||||
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
|
||||
|
||||
@ -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, CorpusPage } from './pages/exam';
|
||||
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import CalendarPage from './pages/user/calendarPage';
|
||||
import SettingsPage from './pages/user/settingsPage';
|
||||
@ -184,8 +184,6 @@ 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>} />
|
||||
|
||||
@ -102,17 +102,6 @@ const PlatformAdminPage: React.FC = () => {
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Destructive reset scope: exam-corpus</Typography>
|
||||
<Typography variant="body2">
|
||||
The <strong>exam-corpus</strong> reset is not limited to public papers. It wipes the entire
|
||||
exam-marker subsystem: public corpus/eb_* data, cc.examboards storage, templates, template
|
||||
layouts, questions, boundaries, response areas, marking batches, student submissions, and
|
||||
mark entries. Use it only when you intend to rebuild all exam-marker data; it does not reset
|
||||
schools, users, or timetable data.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
||||
|
||||
{loading ? (
|
||||
|
||||
@ -1,156 +0,0 @@
|
||||
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;
|
||||
@ -25,8 +25,6 @@ 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';
|
||||
@ -235,17 +233,9 @@ 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>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import {
|
||||
@ -21,7 +21,6 @@ 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';
|
||||
@ -44,8 +43,6 @@ 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;
|
||||
@ -71,28 +68,6 @@ 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 () => {
|
||||
@ -179,29 +154,7 @@ 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>
|
||||
<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>
|
||||
<Typography variant="h6" gutterBottom>Marking queue</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" />
|
||||
|
||||
@ -1,207 +0,0 @@
|
||||
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;
|
||||
@ -1,68 +0,0 @@
|
||||
// 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');
|
||||
}
|
||||
@ -1,6 +1,4 @@
|
||||
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';
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
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 { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||
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'
|
||||
@ -18,7 +13,7 @@ import axios from 'axios'
|
||||
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||
import { logger } from '../../../debugConfig'
|
||||
import { examRepository } from '../../../services/exam/examRepository'
|
||||
import type { AutoMapJobStatus, DigitalTextResponse, ExamTemplateDetail } from '../../../types/exam.types'
|
||||
import type { 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'
|
||||
@ -39,8 +34,6 @@ const PAGE_START_X = 0
|
||||
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
||||
|
||||
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
||||
// S5 coordinate contract: use the actual pdf.js raster dimensions that feed each page src.
|
||||
// Server mapper emits canvas coordinates against the same PAGE_START_X=0 and stacked page heights.
|
||||
let y = 0
|
||||
return pages.map((page) => {
|
||||
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
|
||||
@ -74,33 +67,6 @@ function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
|
||||
|
||||
function reviewSummary(template: ExamTemplateDetail | null) {
|
||||
if (!template) return { ai: 0, unconfirmed: 0, lowConfidence: 0 }
|
||||
const rows = [...(template.questions ?? []), ...(template.response_areas ?? []), ...(template.boundaries ?? []), ...(template.layout ?? [])]
|
||||
return rows.reduce((acc, row) => {
|
||||
if (row.source === 'ai') acc.ai += 1
|
||||
if (row.source === 'ai' && row.confirmed === false) acc.unconfirmed += 1
|
||||
if (typeof row.confidence === 'number' && row.confidence < 0.7) acc.lowConfidence += 1
|
||||
return acc
|
||||
}, { 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
|
||||
}
|
||||
@ -136,16 +102,6 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
||||
contextType: s.props.contextType,
|
||||
questionId: s.props.questionId ?? null,
|
||||
parentId: s.props.parentId ?? null,
|
||||
isContainer: s.props.isContainer ?? false,
|
||||
depth: s.props.depth,
|
||||
description: s.props.description,
|
||||
linkLabel: s.props.linkLabel,
|
||||
meta: (() => { try { return s.props.metaJson ? JSON.parse(s.props.metaJson) : undefined } catch { return undefined } })(),
|
||||
source: s.props.source ?? 'manual',
|
||||
confirmed: s.props.confirmed ?? s.props.source !== 'ai',
|
||||
confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null,
|
||||
derivation: s.props.derivation ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,28 +110,16 @@ function bringDomainShapesToFront(editor: Editor) {
|
||||
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
|
||||
}
|
||||
|
||||
// Draw order = z-order (tldraw stacks by creation). Backdrops (boundaries, preamble bands, container frames)
|
||||
// go first/behind so the figures, responses and parts on top stay hoverable and legible.
|
||||
function zPriority(m: ExamCanvasShapeModel): number {
|
||||
if (m.kind === 'boundary') return 0
|
||||
if (m.kind === 'context' && m.contextType === 'preamble') return 1
|
||||
if (m.kind === 'part' && m.isContainer) return 2
|
||||
if (m.kind === 'part') return 3
|
||||
if (m.kind === 'context') return 5 // figures/tables on top so their description tooltip is reachable
|
||||
return 4 // responses + other regions
|
||||
}
|
||||
|
||||
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||
if (!models.length) return
|
||||
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||
if (existing.length) editor.deleteShapes(existing)
|
||||
if (!models.length) return
|
||||
const ordered = [...models].sort((a, b) => zPriority(a) - zPriority(b))
|
||||
editor.createShapes(ordered.map((m) => ({
|
||||
editor.createShapes(models.map((m) => ({
|
||||
id: createShapeId(m.id),
|
||||
type: SHAPE_TYPES[m.kind],
|
||||
x: m.x,
|
||||
y: m.y,
|
||||
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, parentId: m.parentId ?? undefined, isContainer: m.isContainer ?? undefined, depth: m.depth ?? undefined, description: m.description ?? undefined, linkLabel: m.linkLabel ?? undefined, metaJson: m.meta ? JSON.stringify(m.meta) : undefined, domainId: m.id, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') },
|
||||
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id },
|
||||
})))
|
||||
}
|
||||
|
||||
@ -210,20 +154,6 @@ function seedGuide(editor: Editor) {
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
function isAutoMapAccepted(value: unknown): value is { status: 'accepted'; job_id: string } {
|
||||
return !!value && typeof value === 'object' && (value as { status?: string }).status === 'accepted' && typeof (value as { job_id?: unknown }).job_id === 'string'
|
||||
}
|
||||
|
||||
function autoMapStatusLabel(status: AutoMapJobStatus | null): string {
|
||||
if (!status) return 'Auto-map running'
|
||||
if (status.status === 'queued') return 'Auto-map queued'
|
||||
if (status.status === 'running') return 'Auto-map running'
|
||||
if (status.status === 'completed') return 'Auto-map complete'
|
||||
if (status.status === 'failed') return 'Auto-map failed'
|
||||
return `Auto-map ${status.status}`
|
||||
}
|
||||
|
||||
const ExamTemplateSetupInner: React.FC = () => {
|
||||
const { templateId } = useParams<{ templateId: string }>()
|
||||
const navigate = useNavigate()
|
||||
@ -240,69 +170,6 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
||||
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||
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
|
||||
if (editor) {
|
||||
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
|
||||
loadShapes(editor, shapes)
|
||||
if (!shapes.length) seedGuide(editor)
|
||||
bringDomainShapesToFront(editor)
|
||||
}
|
||||
setDirty(false)
|
||||
}, [])
|
||||
|
||||
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
|
||||
@ -360,62 +227,6 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
|
||||
}, [])
|
||||
|
||||
const pollAutoMapStatus = useCallback(async (jobId: string) => {
|
||||
if (!templateId) return
|
||||
try {
|
||||
const status = await examRepository.getAutoMapStatus(templateId, jobId)
|
||||
setAutoMapStatus(status)
|
||||
if (status.status === 'completed') {
|
||||
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
|
||||
autoMapPollRef.current = null
|
||||
setAutoMapBusy(false)
|
||||
const detail = status.template ?? await examRepository.getTemplate(templateId)
|
||||
applyTemplateToCanvas(detail)
|
||||
return
|
||||
}
|
||||
if (status.status === 'failed') {
|
||||
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
|
||||
autoMapPollRef.current = null
|
||||
setAutoMapBusy(false)
|
||||
setError(status.error ?? 'Auto-map failed; existing template state was preserved.')
|
||||
return
|
||||
}
|
||||
autoMapPollRef.current = window.setTimeout(() => void pollAutoMapStatus(jobId), 2500)
|
||||
} catch (e) {
|
||||
const msg = apiMessage(e).message
|
||||
setAutoMapBusy(false)
|
||||
setError(msg)
|
||||
logger.warn('cc-exam-marker', 'Auto-map status poll failed', { templateId, jobId, message: msg })
|
||||
}
|
||||
}, [applyTemplateToCanvas, templateId])
|
||||
|
||||
const autoMapFromPdf = useCallback(async () => {
|
||||
if (!templateId || autoMapBusy) return
|
||||
let queued = false
|
||||
setAutoMapBusy(true); setAutoMapStatus(null); setError(null); setConflict(null)
|
||||
try {
|
||||
const result = await examRepository.autoMapTemplate(templateId)
|
||||
if (isAutoMapAccepted(result)) {
|
||||
queued = true
|
||||
setAutoMapStatus({ job_id: result.job_id, status: 'queued', template_id: templateId })
|
||||
await pollAutoMapStatus(result.job_id)
|
||||
return
|
||||
}
|
||||
setAutoMapStatus(null)
|
||||
applyTemplateToCanvas(result)
|
||||
} catch (e) {
|
||||
const msg = apiMessage(e)
|
||||
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
|
||||
logger.warn('cc-exam-marker', 'Auto-map request failed', { templateId, message: msg.message })
|
||||
} finally {
|
||||
if (!queued) setAutoMapBusy(false)
|
||||
}
|
||||
}, [applyTemplateToCanvas, autoMapBusy, pollAutoMapStatus, templateId])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || !templateId || !template) return
|
||||
@ -437,11 +248,6 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
}
|
||||
}, [template, templateId])
|
||||
|
||||
const layoutSummary = useMemo(() => {
|
||||
const rows = (template?.layout ?? []).filter((row) => row.margins_enabled && row.margin_left !== null && row.margin_right !== null)
|
||||
return rows.slice(0, 4).map((row) => `P${row.page_index + 1} ${row.role ?? 'page'} L${Math.round(row.margin_left ?? 0)} R${Math.round(row.margin_right ?? 0)} T${Math.round(row.margin_top ?? 0)} B${Math.round(row.margin_bottom ?? 0)}`)
|
||||
}, [template?.layout])
|
||||
|
||||
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
||||
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||||
<Button
|
||||
@ -472,15 +278,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
</Tooltip>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
|
||||
<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>
|
||||
|
||||
@ -504,54 +302,15 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||
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' })
|
||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||
applyDocViewConstraints(editor, [])
|
||||
editor.resetZoom()
|
||||
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
||||
// (Previously `else seedGuide` fired on mount while `template` was still null during
|
||||
// the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.)
|
||||
if (template) {
|
||||
const s = shapesFromTemplate(template, pageGeometriesRef.current)
|
||||
loadShapes(editor, s)
|
||||
if (!s.length) seedGuide(editor)
|
||||
}
|
||||
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
||||
bringDomainShapesToFront(editor)
|
||||
}}
|
||||
/>
|
||||
</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' } }}>
|
||||
@ -564,11 +323,8 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) AI suggestions render dashed/translucent with confidence and cheap review flags; manual shapes stay solid.
|
||||
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
|
||||
</Typography>
|
||||
<Alert severity={review.unconfirmed || review.lowConfidence ? 'warning' : 'info'} variant="outlined" sx={{ my: 1 }}>
|
||||
Review layer: {review.ai} AI suggestions, {review.unconfirmed} unconfirmed, {review.lowConfidence} below 70% confidence. Cheap flags include overlap, missing marks, uncertain labels, low confidence, and unconfirmed AI.
|
||||
</Alert>
|
||||
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
|
||||
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
|
||||
const p = canvasShapePalette[kind]
|
||||
@ -581,9 +337,6 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
||||
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
@ -593,32 +346,6 @@ 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>
|
||||
)
|
||||
|
||||
@ -4,7 +4,6 @@ import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T
|
||||
import type { TLHandle } from '@tldraw/tldraw'
|
||||
import { PAGE_WIDTH } from '../../../utils/exam-canvas/model'
|
||||
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||
import type { ExamTemplateSource } from '../../../types/exam.types'
|
||||
|
||||
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||
|
||||
@ -35,18 +34,7 @@ export type ExamCanvasTLShape = TLBaseBoxShape & {
|
||||
responseForm?: string
|
||||
contextType?: string
|
||||
questionId?: string | null
|
||||
parentId?: string | null
|
||||
isContainer?: boolean
|
||||
depth?: number
|
||||
description?: string
|
||||
linkLabel?: string
|
||||
metaJson?: string
|
||||
domainId?: string
|
||||
source?: 'manual' | 'ai'
|
||||
confirmed?: boolean
|
||||
confidence?: number | null
|
||||
derivation?: string | null
|
||||
reviewFlags?: string
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,149 +60,26 @@ 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); }
|
||||
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
|
||||
.exam-canvas-shape__flag { background: rgba(251,191,36,.94); color: #78350f; box-shadow: 0 1px 4px rgba(120,53,15,.18); }
|
||||
.exam-canvas-shape__confidence { background: rgba(15,23,42,.82); color: #fff; }
|
||||
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||
[data-color-mode="dark"] .exam-canvas-shape__flag, .tl-theme__dark .exam-canvas-shape__flag { background: rgba(251,191,36,.88); color: #422006; }
|
||||
`
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function provenanceTitle(shape: ExamCanvasTLShape, base: string) {
|
||||
const bits = [base]
|
||||
if (shape.props.source === 'ai') bits.push(shape.props.confirmed === false ? 'AI suggestion, unconfirmed' : 'AI, confirmed')
|
||||
const confidence = confidenceLabel(shape.props.confidence)
|
||||
if (confidence) bits.push(`confidence ${confidence}`)
|
||||
if (shape.props.derivation) bits.push(`derivation: ${shape.props.derivation}`)
|
||||
const flags = reviewFlags(shape)
|
||||
if (flags.length) bits.push(`review flags: ${flags.join(', ')}`)
|
||||
return bits.join(' • ')
|
||||
}
|
||||
|
||||
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={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} />
|
||||
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={p.dash} strokeLinecap="round" 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">{isGhost ? 'AI' : p.icon}</span>
|
||||
<span aria-hidden="true">{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>}
|
||||
{flags.slice(0, 2).map((flag, index) => <span key={flag} className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, top: 10 + index * 22, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px' }}>{flag}</span>)}
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// Distinct look for a container box (main question / intermediate part). It frames its children rather than
|
||||
// filling over them: transparent interior + a coloured border whose hue shifts by nesting depth, so
|
||||
// question ⊃ part ⊃ subpart reads as nested frames on the paper.
|
||||
const CONTAINER_DEPTH_STROKE = ['#4f46e5', '#0d9488', '#b45309']
|
||||
function containerStroke(depth: number) { return CONTAINER_DEPTH_STROKE[Math.min(depth, CONTAINER_DEPTH_STROKE.length - 1)] }
|
||||
|
||||
function marksPill(maxMarks: number | undefined) {
|
||||
if (typeof maxMarks !== 'number' || maxMarks <= 0) return null
|
||||
return (
|
||||
<span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 3 }} title={`${maxMarks} mark${maxMarks === 1 ? '' : 's'}`}>
|
||||
<span aria-hidden="true" style={{ opacity: 0.7 }}>◆</span>{maxMarks}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function parseMeta(shape: ExamCanvasTLShape): Record<string, any> | null {
|
||||
try { return shape.props.metaJson ? JSON.parse(shape.props.metaJson) : null } catch { return null }
|
||||
}
|
||||
|
||||
// Draw the recognised answer sub-structure INSIDE a response box, using the box-relative [0,1] coords the
|
||||
// contract now carries: numbered sub-answer bands ("suggest two" → 1, 2), per-option MC selection boxes, and
|
||||
// the final-answer line's expected quantity/unit.
|
||||
function renderResponseSubstructure(shape: ExamCanvasTLShape): React.ReactNode {
|
||||
const m = parseMeta(shape)
|
||||
if (!m) return null
|
||||
const pct = (v: any, d = 0) => `${Math.max(0, Math.min(1, Number(v ?? d))) * 100}%`
|
||||
const els: React.ReactNode[] = []
|
||||
const comps: any[] = Array.isArray(m.components) ? m.components : []
|
||||
comps.forEach((c, i) => {
|
||||
if (i === 0) return // first band starts at the box top; a divider there is noise
|
||||
els.push(<div key={`cd${i}`} style={{ position: 'absolute', left: 0, right: 0, top: pct(c.rel_y0), borderTop: '1px dashed var(--exam-stroke)', opacity: 0.45 }} />)
|
||||
})
|
||||
comps.forEach((c, i) => els.push(
|
||||
<span key={`cl${i}`} className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 4, top: `calc(${pct(c.rel_y0)} + 2px)`, fontSize: 10, fontWeight: 900, borderRadius: 6, padding: '0 5px', opacity: 0.95 }}>{c.label ?? i + 1}</span>
|
||||
))
|
||||
const boxes: any[] = Array.isArray(m.boxes) ? m.boxes : []
|
||||
boxes.forEach((b, i) => {
|
||||
const rel = b.rel || {}
|
||||
const filled = Number(b.fill ?? 0) > 0.35
|
||||
els.push(<div key={`bx${i}`} title={`option ${i + 1}${filled ? ' (marked)' : ''}`} style={{ position: 'absolute', left: pct(rel.x0), top: pct(rel.y0), width: pct(Number(rel.x1) - Number(rel.x0)), height: pct(Number(rel.y1) - Number(rel.y0)), border: '1.5px solid var(--exam-stroke)', borderRadius: 3, background: filled ? 'var(--exam-stroke)' : 'transparent', opacity: filled ? 0.5 : 0.85, boxSizing: 'border-box' }} />)
|
||||
})
|
||||
// "tick N" instruction for multiple-choice (select_n = how many boxes to tick)
|
||||
const selectN = Number(m.select_n ?? 0)
|
||||
if (selectN > 0 && boxes.length) {
|
||||
els.push(<span key="tickn" className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 4, top: 2, fontSize: 10, fontWeight: 900, borderRadius: 6, padding: '0 5px', opacity: 0.95 }}>tick {selectN}</span>)
|
||||
}
|
||||
const fal = m.final_answer_line
|
||||
if (fal && (fal.unit || fal.quantity)) {
|
||||
els.push(<span key="fal" className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 4, top: `calc(${pct(fal.rel_y, 0.9)} - 8px)`, maxWidth: 'calc(100% - 8px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 10, fontWeight: 800, borderRadius: 6, padding: '0 5px', opacity: 0.95 }} title="expected final answer">{[fal.quantity, fal.unit && `(${fal.unit})`].filter(Boolean).join(' ')}</span>)
|
||||
}
|
||||
return els.length ? <>{els}</> : null
|
||||
}
|
||||
|
||||
// A container question is a BACKDROP, not a big box over the content: the main question (depth 0) relies on
|
||||
// its start/end boundary rules for extent and shows only a small corner tab (label + total marks); an
|
||||
// intermediate part-group (depth ≥ 1) also gets a faint dashed frame to convey nesting. Non-interactive so it
|
||||
// never blocks a hover on the figures/responses on top.
|
||||
function renderContainerTab(shape: ExamCanvasTLShape) {
|
||||
const depth = shape.props.depth ?? 0
|
||||
const stroke = containerStroke(depth)
|
||||
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
|
||||
const marks = shape.props.maxMarks
|
||||
const title = provenanceTitle(shape, depth === 0 ? 'Question (container)' : 'Part group (container)')
|
||||
return (
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none', overflow: 'visible' }}>
|
||||
<style>{shapeCss}</style>
|
||||
{depth > 0 && <div style={{ position: 'absolute', inset: 0, border: `1px dashed ${stroke}`, borderRadius: 12, opacity: 0.3, boxSizing: 'border-box' }} />}
|
||||
<span className="exam-canvas-shape__pill" title={title} style={{ position: 'absolute', left: 0, top: -3, display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, fontWeight: 900, borderRadius: 8, padding: '2px 7px', color: stroke, border: `1.5px solid ${stroke}`, background: 'rgba(255,255,255,0.92)', whiteSpace: 'nowrap' }}>
|
||||
<span aria-hidden="true">{isAiSuggestion ? 'AI' : (depth === 0 ? '▣' : '▢')}</span>
|
||||
{shape.props.label}
|
||||
{typeof marks === 'number' && marks > 0 && <span style={{ opacity: 0.85 }}>· {marks}m</span>}
|
||||
</span>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
@ -223,52 +88,32 @@ function renderShape(shape: ExamCanvasTLShape) {
|
||||
const kind = shape.props.kind
|
||||
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||
const isBoundary = kind === 'boundary'
|
||||
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
|
||||
if (isBoundary) return renderBoundaryLine(shape)
|
||||
const isContainer = shape.props.isContainer === true && kind === 'part'
|
||||
if (isContainer) return renderContainerTab(shape)
|
||||
const isPreamble = kind === 'context' && shape.props.contextType === 'preamble'
|
||||
const depth = shape.props.depth ?? 0
|
||||
const stroke = isContainer ? containerStroke(depth) : p.stroke
|
||||
const darkStroke = isContainer ? containerStroke(depth) : p.darkStroke
|
||||
const confidence = confidenceLabel(shape.props.confidence)
|
||||
const flags = reviewFlags(shape)
|
||||
const roleLabel = isContainer ? (depth === 0 ? 'Question: container' : 'Part: container') : `${p.label}: ${p.role}`
|
||||
const title = shape.props.description ? `${provenanceTitle(shape, roleLabel)} • ${shape.props.description}` : provenanceTitle(shape, roleLabel)
|
||||
return (
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: isPreamble ? 'none' : 'all' }}>
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||
<style>{shapeCss}</style>
|
||||
<div
|
||||
className={`exam-canvas-shape exam-canvas-shape--${kind}${isContainer ? ' exam-canvas-shape--container' : ''}`}
|
||||
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
|
||||
style={{
|
||||
'--exam-light-stroke': stroke,
|
||||
'--exam-light-stroke': p.stroke,
|
||||
'--exam-light-fill': p.fill,
|
||||
'--exam-dark-stroke': darkStroke,
|
||||
'--exam-dark-stroke': p.darkStroke,
|
||||
'--exam-dark-fill': p.darkFill,
|
||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isContainer ? 2.5 : 1.5}px ${isContainer ? 'solid' : (isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid')} var(--exam-stroke)`,
|
||||
borderRadius: isContainer ? 14 : 10,
|
||||
background: isContainer ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
|
||||
padding: 8, boxShadow: isContainer ? 'none' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||
background: isBoundary ? 'transparent' : 'var(--exam-fill)', 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}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
aria-label={`${p.label}: ${p.role}`}
|
||||
title={`${p.label}: ${p.role}`}
|
||||
>
|
||||
{kind === 'response' && renderResponseSubstructure(shape)}
|
||||
<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, maxWidth: 'calc(100% - 60px)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', zIndex: 1 }}>
|
||||
<span aria-hidden="true">{isAiSuggestion ? 'AI' : (isContainer ? (depth === 0 ? '▣' : '▢') : p.icon)}</span>
|
||||
<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">{p.icon}</span>
|
||||
{shape.props.label || p.label}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
{marksPill(shape.props.maxMarks)}
|
||||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||
{!confidence && !marksPill(shape.props.maxMarks) && shape.props.questionId && kind !== 'context' && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
|
||||
</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>}
|
||||
{/* Context region: a visible tether to its owning question + the figure kind. */}
|
||||
{kind === 'context' && shape.props.linkLabel && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.95 }}>→ {shape.props.linkLabel}</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 && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
|
||||
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
@ -276,10 +121,10 @@ function renderShape(shape: ExamCanvasTLShape) {
|
||||
|
||||
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||
const p = canvasShapePalette[kind]
|
||||
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, source: 'manual' as const, confirmed: true }
|
||||
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||
}
|
||||
|
||||
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), parentId: T.optional(T.string), isContainer: T.optional(T.boolean), depth: T.optional(T.number), description: T.optional(T.string), linkLabel: T.optional(T.string), metaJson: T.optional(T.string), domainId: T.optional(T.string), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: T.optional(T.string), reviewFlags: T.optional(T.string) }
|
||||
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) }
|
||||
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||||
|
||||
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
||||
|
||||
@ -11,29 +11,20 @@ import { API_BASE } from '../../config/apiConfig';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import type {
|
||||
AutoMapJobStatus,
|
||||
AutoMapResponse,
|
||||
BankResponse,
|
||||
CorpusResponse,
|
||||
DigitalTextResponse,
|
||||
BatchQueueResponse,
|
||||
BatchResultsResponse,
|
||||
CreateBatchPayload,
|
||||
CreateCustomPaperPayload,
|
||||
CreateCustomPaperResult,
|
||||
CreateTemplatePayload,
|
||||
ExamBoundary,
|
||||
ExamQuestion,
|
||||
ExamResponseArea,
|
||||
ExamTemplate,
|
||||
ExamTemplateDetail,
|
||||
ExamTemplateLayout,
|
||||
MarkingBatch,
|
||||
MarkUpsertPayload,
|
||||
Neo4jSyncResult,
|
||||
PatchQuestionPayload,
|
||||
SpecPoint,
|
||||
StudentSubmission,
|
||||
TemplateReplacePayload,
|
||||
UpdateTemplateMetaPayload,
|
||||
} from '../../types/exam.types';
|
||||
@ -73,10 +64,6 @@ function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
|
||||
spec_ref: q.spec_ref,
|
||||
bounds: q.bounds ?? null,
|
||||
page: q.page ?? null,
|
||||
source: q.source ?? 'manual',
|
||||
confirmed: q.confirmed ?? true,
|
||||
confidence: q.confidence ?? null,
|
||||
derivation: q.derivation ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -92,8 +79,6 @@ function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, d
|
||||
source: r.source,
|
||||
confirmed: r.confirmed,
|
||||
confidence: r.confidence,
|
||||
mark_subtype: r.mark_subtype ?? null,
|
||||
derivation: r.derivation ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -107,26 +92,6 @@ function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate
|
||||
bounds: b.bounds,
|
||||
source: b.source,
|
||||
confirmed: b.confirmed,
|
||||
confidence: b.confidence ?? null,
|
||||
derivation: b.derivation ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function layoutPayload(layout: ExamTemplateLayout, duplicate = false) {
|
||||
return {
|
||||
id: duplicate ? newUuid() : layout.id,
|
||||
page_index: layout.page_index,
|
||||
role: layout.role ?? null,
|
||||
margin_left: layout.margin_left ?? null,
|
||||
margin_right: layout.margin_right ?? null,
|
||||
margin_top: layout.margin_top ?? null,
|
||||
margin_bottom: layout.margin_bottom ?? null,
|
||||
margins_enabled: layout.margins_enabled ?? true,
|
||||
source: layout.source ?? 'manual',
|
||||
confirmed: layout.confirmed ?? true,
|
||||
confidence: layout.confidence ?? null,
|
||||
derivation: layout.derivation ?? null,
|
||||
meta: layout.meta ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@ -148,7 +113,6 @@ async function replaceTemplate(
|
||||
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)),
|
||||
layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)),
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
@ -165,30 +129,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 });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async autoMapTemplate(templateId: string): Promise<AutoMapResponse> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.post<AutoMapResponse>(`${EXAM_BASE}/templates/${templateId}/auto-map`, {}, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getAutoMapStatus(templateId: string, jobId: string): Promise<AutoMapJobStatus> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<AutoMapJobStatus>(`${EXAM_BASE}/templates/${templateId}/auto-map/${jobId}/status`, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
|
||||
@ -310,42 +256,6 @@ 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;
|
||||
|
||||
@ -1,17 +1,9 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
import { logger } from './debugConfig';
|
||||
|
||||
const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
// Same-origin proxy support: a leading-slash value (e.g. "/__supabase") is
|
||||
// resolved against the current browser origin so supabase-js receives an
|
||||
// absolute URL while every request stays same-origin (no CORS) and routes
|
||||
// through the app host's /__supabase nginx proxy to the backend Supabase.
|
||||
const supabaseUrl = rawSupabaseUrl?.startsWith('/')
|
||||
? `${window.location.origin}${rawSupabaseUrl}`
|
||||
: rawSupabaseUrl;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Missing Supabase configuration');
|
||||
}
|
||||
|
||||
@ -75,8 +75,6 @@ export interface UpdateTemplateMetaPayload {
|
||||
}
|
||||
|
||||
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||
export type ExamTemplateSource = 'manual' | 'ai';
|
||||
|
||||
export interface ExamQuestion {
|
||||
id: string;
|
||||
template_id: string;
|
||||
@ -91,14 +89,6 @@ export interface ExamQuestion {
|
||||
spec_ref: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
page?: number | null;
|
||||
/** Analyse contract v2 (migration 78): primary command verb for a leaf part. */
|
||||
command_word?: string | null;
|
||||
/** Analyse contract v2 (migration 78): stem prose for the outline view. */
|
||||
preamble?: Record<string, unknown> | null;
|
||||
source: ExamTemplateSource;
|
||||
confirmed: boolean;
|
||||
confidence: number | null;
|
||||
derivation: string | null;
|
||||
}
|
||||
|
||||
export type ExamResponseAreaKind =
|
||||
@ -109,8 +99,6 @@ export type ExamResponseAreaKind =
|
||||
| 'reference'
|
||||
| 'furniture';
|
||||
|
||||
export type ExamMarkSubtype = 'part_marks' | 'question_total' | 'grader_box';
|
||||
|
||||
export interface ExamResponseArea {
|
||||
id: string;
|
||||
question_id: string;
|
||||
@ -120,13 +108,9 @@ export interface ExamResponseArea {
|
||||
kind: ExamResponseAreaKind;
|
||||
response_form: string | null;
|
||||
context_type?: string | null;
|
||||
/** Rich recognition payload (migration 75): figure name/description, OMR box geometry, unit/quantity, table… */
|
||||
meta?: Record<string, unknown> | null;
|
||||
source: ExamTemplateSource;
|
||||
source: 'manual' | 'ai';
|
||||
confirmed: boolean;
|
||||
confidence: number | null;
|
||||
mark_subtype?: ExamMarkSubtype | null;
|
||||
derivation?: string | null;
|
||||
}
|
||||
|
||||
export interface ExamBoundary {
|
||||
@ -137,36 +121,14 @@ export interface ExamBoundary {
|
||||
page_index: number;
|
||||
y: number;
|
||||
bounds: Record<string, number> | null;
|
||||
source: ExamTemplateSource;
|
||||
source: 'manual' | 'ai';
|
||||
confirmed: boolean;
|
||||
confidence: number | null;
|
||||
derivation: string | null;
|
||||
}
|
||||
|
||||
export interface ExamTemplateLayout {
|
||||
id: string;
|
||||
template_id: string;
|
||||
page_index: number;
|
||||
role: string | null;
|
||||
margin_left: number | null;
|
||||
margin_right: number | null;
|
||||
margin_top: number | null;
|
||||
margin_bottom: number | null;
|
||||
margins_enabled: boolean;
|
||||
source: ExamTemplateSource;
|
||||
confirmed: boolean;
|
||||
confidence: number | null;
|
||||
derivation: string | null;
|
||||
meta: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ExamTemplateDetail extends ExamTemplate {
|
||||
questions: ExamQuestion[];
|
||||
response_areas: ExamResponseArea[];
|
||||
boundaries: ExamBoundary[];
|
||||
layout: ExamTemplateLayout[];
|
||||
}
|
||||
|
||||
|
||||
@ -190,10 +152,6 @@ export interface TemplateReplacePayload {
|
||||
spec_ref?: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
page?: number | null;
|
||||
source?: ExamTemplateSource;
|
||||
confirmed?: boolean;
|
||||
confidence?: number | null;
|
||||
derivation?: string | null;
|
||||
}>;
|
||||
response_areas: Array<{
|
||||
id?: string;
|
||||
@ -203,12 +161,9 @@ export interface TemplateReplacePayload {
|
||||
kind: ExamResponseArea['kind'];
|
||||
response_form?: string | null;
|
||||
context_type?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
source?: 'manual' | 'ai';
|
||||
confirmed?: boolean;
|
||||
confidence?: number | null;
|
||||
mark_subtype?: ExamMarkSubtype | null;
|
||||
derivation?: string | null;
|
||||
}>;
|
||||
boundaries: Array<{
|
||||
id?: string;
|
||||
@ -217,25 +172,8 @@ export interface TemplateReplacePayload {
|
||||
page_index: number;
|
||||
y: number;
|
||||
bounds?: Record<string, number> | null;
|
||||
source?: ExamTemplateSource;
|
||||
source?: 'manual' | 'ai';
|
||||
confirmed?: boolean;
|
||||
confidence?: number | null;
|
||||
derivation?: string | null;
|
||||
}>;
|
||||
layout?: Array<{
|
||||
id?: string;
|
||||
page_index: number;
|
||||
role?: string | null;
|
||||
margin_left?: number | null;
|
||||
margin_right?: number | null;
|
||||
margin_top?: number | null;
|
||||
margin_bottom?: number | null;
|
||||
margins_enabled?: boolean;
|
||||
source?: ExamTemplateSource;
|
||||
confirmed?: boolean;
|
||||
confidence?: number | null;
|
||||
derivation?: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@ -264,23 +202,6 @@ export interface Neo4jSyncResult {
|
||||
projection?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AutoMapAcceptedResponse {
|
||||
status: 'accepted';
|
||||
job_id: string;
|
||||
}
|
||||
|
||||
export interface AutoMapJobStatus {
|
||||
job_id: string;
|
||||
status: 'queued' | 'running' | 'completed' | 'failed' | string;
|
||||
template_id: string;
|
||||
updated_at?: number;
|
||||
counts?: Record<string, number>;
|
||||
error?: string;
|
||||
template?: ExamTemplateDetail;
|
||||
}
|
||||
|
||||
export type AutoMapResponse = ExamTemplateDetail | AutoMapAcceptedResponse;
|
||||
|
||||
export interface MarkingBatch {
|
||||
id: string;
|
||||
template_id: string;
|
||||
@ -345,71 +266,3 @@ 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 }[];
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './m
|
||||
|
||||
const template: ExamTemplateDetail = {
|
||||
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
||||
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], layout: [],
|
||||
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
|
||||
}
|
||||
|
||||
describe('exam setup canvas serialization', () => {
|
||||
@ -51,100 +51,17 @@ describe('exam setup canvas serialization', () => {
|
||||
const shapes = shapesFromTemplate({
|
||||
...template,
|
||||
questions: [
|
||||
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
|
||||
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
|
||||
],
|
||||
response_areas: [
|
||||
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
|
||||
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
|
||||
],
|
||||
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true, confidence: null, derivation: null }],
|
||||
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
||||
})
|
||||
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
|
||||
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
||||
})
|
||||
|
||||
it('carries AI provenance into canvas models, flags cheap review issues, and preserves it on save', () => {
|
||||
const detail: ExamTemplateDetail = {
|
||||
...template,
|
||||
questions: [
|
||||
{ id: '11111111-1111-4111-8111-111111111111', template_id: 'tpl-1', parent_id: null, label: 'Q?', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||
{ id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q?', order: 0, max_marks: 0, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 100, y: 120, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||
{ id: '66666666-6666-4666-8666-666666666666', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q1(b)', order: 1, max_marks: 1, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 150, y: 150, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.8, derivation: 'g6' },
|
||||
],
|
||||
response_areas: [
|
||||
{ id: '33333333-3333-4333-8333-333333333333', question_id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', page: 1, bounds: { x: 120, y: 140, w: 120, h: 40 }, kind: 'response', response_form: 'lines', source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' },
|
||||
],
|
||||
boundaries: [
|
||||
{ id: '44444444-4444-4444-8444-444444444444', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||
{ id: '55555555-5555-4555-8555-555555555555', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? end', page_index: 0, y: 500, bounds: { x: 0, y: 500, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||
],
|
||||
}
|
||||
const shapes = shapesFromTemplate(detail)
|
||||
const part = shapes.find((shape) => shape.kind === 'part')
|
||||
expect(part).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||
expect(part?.reviewFlags).toEqual(expect.arrayContaining(['unconfirmed AI', 'low confidence', 'uncertain question label', 'missing marks', 'overlapping shapes']))
|
||||
|
||||
const payload = serializeCanvasShapes(template, shapes)
|
||||
expect(payload.questions.find((q) => q.id === '22222222-2222-4222-8222-222222222222')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||
expect(payload.response_areas.find((r) => r.id === '33333333-3333-4333-8333-333333333333')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' })
|
||||
expect(payload.boundaries.find((b) => b.id === '44444444-4444-4444-8444-444444444444')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||
})
|
||||
|
||||
it('never drops a response region that has no enclosing part — it falls back to a question', () => {
|
||||
const payload = serializeCanvasShapes(template, [
|
||||
{ id: 'b-top', kind: 'boundary', x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
|
||||
{ id: 'b-bottom', kind: 'boundary', x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
|
||||
{ id: 'part-1', kind: 'part', x: 100, y: 180, w: 400, h: 120, label: 'Q1(a)', maxMarks: 3 },
|
||||
{ id: 'resp-far', kind: 'response', x: 100, y: 520, w: 300, h: 90, responseForm: 'lines' }, // outside the part box
|
||||
])
|
||||
const part = payload.questions.find((q) => !q.is_container)
|
||||
expect(payload.response_areas).toHaveLength(1) // previously dropped (no containing part)
|
||||
expect(payload.response_areas[0].question_id).toBe(part?.id) // falls back to the part, not lost
|
||||
})
|
||||
|
||||
it('preserves a 3-deep question → part → subpart chain across a round-trip save', () => {
|
||||
const Q = '11111111-1111-4111-8111-111111111111' // container, depth 0
|
||||
const P = '22222222-2222-4222-8222-222222222222' // container part, depth 1
|
||||
const S = '33333333-3333-4333-8333-333333333333' // leaf subpart, depth 2
|
||||
const detail: ExamTemplateDetail = {
|
||||
...template,
|
||||
questions: [
|
||||
{ id: Q, template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 6, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, bounds: { x: 40, y: 80, w: 700, h: 600 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' },
|
||||
{ id: P, template_id: 'tpl-1', parent_id: Q, label: 'Q1(a)', order: 1, max_marks: 6, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, bounds: { x: 60, y: 120, w: 660, h: 500 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' },
|
||||
{ id: S, template_id: 'tpl-1', parent_id: P, label: 'Q1(a)(i)', order: 2, max_marks: 3, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 80, y: 160, w: 620, h: 200 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' },
|
||||
],
|
||||
response_areas: [],
|
||||
boundaries: [],
|
||||
}
|
||||
const shapes = shapesFromTemplate(detail)
|
||||
// all three tiers are drawn (containers are no longer skipped), with depth + container flags
|
||||
expect(shapes.filter((s) => s.kind === 'part')).toHaveLength(3)
|
||||
expect(shapes.find((s) => s.id === S)).toMatchObject({ isContainer: false, depth: 2, parentId: P })
|
||||
expect(shapes.find((s) => s.id === Q)).toMatchObject({ isContainer: true, depth: 0 })
|
||||
|
||||
const payload = serializeCanvasShapes(template, shapes)
|
||||
const byId = new Map(payload.questions.map((q) => [q.id, q]))
|
||||
expect(payload.questions).toHaveLength(3)
|
||||
expect(byId.get(Q)?.parent_id ?? null).toBeNull()
|
||||
expect(byId.get(P)?.parent_id).toBe(Q) // middle tier survives — not flattened away
|
||||
expect(byId.get(S)?.parent_id).toBe(P) // 3-deep chain intact
|
||||
expect(byId.get(Q)?.is_container).toBe(true)
|
||||
expect(byId.get(S)?.is_container).toBe(false)
|
||||
expect(byId.get(S)?.max_marks).toBe(3)
|
||||
})
|
||||
|
||||
it('respects a region persisted question over geometric nearest when no part contains it', () => {
|
||||
const A = '11111111-1111-4111-8111-111111111111'
|
||||
const B = '22222222-2222-4222-8222-222222222222'
|
||||
const R = '33333333-3333-4333-8333-333333333333'
|
||||
const payload = serializeCanvasShapes(template, [
|
||||
{ id: 'pa', kind: 'part', x: 100, y: 120, w: 300, h: 80, label: 'A', questionId: A },
|
||||
{ id: 'pb', kind: 'part', x: 100, y: 500, w: 300, h: 80, label: 'B', questionId: B },
|
||||
{ id: R, kind: 'response', x: 120, y: 260, w: 200, h: 60, questionId: B }, // contained by neither; nearest is A
|
||||
])
|
||||
expect(payload.response_areas.find((r) => r.id === R)?.question_id).toBe(B) // persisted B wins over nearest A
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
import type { ExamTemplateDetail, ExamTemplateSource, TemplateReplacePayload } from '../../types/exam.types'
|
||||
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
|
||||
|
||||
export const PAGE_HEIGHT = 1100
|
||||
export const PAGE_WIDTH = 780
|
||||
@ -10,7 +10,7 @@ export interface CanvasPageGeometry { pageNumber: number; x: number; y: number;
|
||||
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||
|
||||
export interface CanvasBounds extends Record<string, number> { x: number; y: number; w: number; h: number }
|
||||
export interface CanvasBounds { x: number; y: number; w: number; h: number }
|
||||
|
||||
export interface ExamCanvasShapeModel {
|
||||
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
||||
@ -26,24 +26,6 @@ export interface ExamCanvasShapeModel {
|
||||
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
||||
contextType?: string
|
||||
questionId?: string | null
|
||||
/** Tree parent (container question) id — preserved across a round-trip so N-deep structure survives a save. */
|
||||
parentId?: string | null
|
||||
/** True for a container question (main question / intermediate part) drawn as a nesting box. */
|
||||
isContainer?: boolean
|
||||
/** 0 = main question, 1 = part, 2 = subpart … drives nesting styling. */
|
||||
depth?: number
|
||||
/** Figure/table caption for a context region (shown as a tooltip). */
|
||||
description?: string
|
||||
/** Label of the owning question, for a context region's visible tether pill. */
|
||||
linkLabel?: string
|
||||
/** Rich recognition detail (exam_response_areas.meta): answer components, MC option boxes, final-answer
|
||||
* line unit/quantity, figure name/description. Carried through so the canvas can draw sub-structure. */
|
||||
meta?: Record<string, unknown> | null
|
||||
source?: ExamTemplateSource
|
||||
confirmed?: boolean
|
||||
confidence?: number | null
|
||||
derivation?: string | null
|
||||
reviewFlags?: string[]
|
||||
}
|
||||
|
||||
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
|
||||
@ -96,22 +78,6 @@ function boundaryBounds(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: Ca
|
||||
return { x: page.x, y: shape.y, w: page.w, h: 8 }
|
||||
}
|
||||
|
||||
function persistedSource(shape: ExamCanvasShapeModel): ExamTemplateSource {
|
||||
return shape.source ?? 'manual'
|
||||
}
|
||||
|
||||
function persistedConfirmed(shape: ExamCanvasShapeModel): boolean {
|
||||
return shape.confirmed ?? persistedSource(shape) !== 'ai'
|
||||
}
|
||||
|
||||
function persistedConfidence(shape: ExamCanvasShapeModel): number | null {
|
||||
return typeof shape.confidence === 'number' ? shape.confidence : null
|
||||
}
|
||||
|
||||
function persistedDerivation(shape: ExamCanvasShapeModel): string | null {
|
||||
return shape.derivation ?? null
|
||||
}
|
||||
|
||||
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
||||
const ox2 = outer.x + outer.w
|
||||
const oy2 = outer.y + outer.h
|
||||
@ -131,167 +97,65 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
||||
const orderedBoundaries = shapes
|
||||
.filter((s) => s.kind === 'boundary')
|
||||
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
|
||||
// 'part' shapes carry the whole question tree: containers (isContainer) + leaf parts/subparts. They are the
|
||||
// authoritative structure — the boundary-pair synthesis below is only a fallback for the manual workflow.
|
||||
const structural = shapes.filter((s) => s.kind === 'part')
|
||||
const containerShapes = structural.filter((s) => s.isContainer)
|
||||
const parts = shapes.filter((s) => s.kind === 'part')
|
||||
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
||||
|
||||
const questions: TemplateReplacePayload['questions'] = []
|
||||
const boundaries: TemplateReplacePayload['boundaries'] = []
|
||||
const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = []
|
||||
|
||||
// Stable question id per structural shape — prefer the persisted domain id so a re-save keeps join keys.
|
||||
const idOf = new Map<string, string>()
|
||||
for (const s of structural) idOf.set(s.id, isUuid(s.questionId) ? (s.questionId as string) : isUuid(s.id) ? s.id : newDomainId())
|
||||
const emittedIds = new Set<string>(idOf.values())
|
||||
|
||||
// Boundary pairs: attach each pair to the container shape it spans (AI/structured path), else SYNTHESIZE a
|
||||
// container question from the pair (pure manual authoring, no container box drawn). v1 always synthesized —
|
||||
// which overwrote real containers and flattened the tree.
|
||||
for (let i = 0; i < orderedBoundaries.length; i += 2) {
|
||||
const top = orderedBoundaries[i]
|
||||
const bottom = orderedBoundaries[i + 1]
|
||||
if (!top || !bottom) break
|
||||
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${bands.length + 1}`
|
||||
// A container box whose vertical extent spans this start/end pair owns the boundary (structured path);
|
||||
// otherwise the pair itself defines a container (manual path).
|
||||
const pairTop = Math.min(top.y, bottom.y)
|
||||
const pairBottom = Math.max(top.y, bottom.y)
|
||||
const cover = containerShapes.find((c) => c.y <= pairTop + 4 && c.y + c.h >= pairBottom - 4)
|
||||
let questionId: string
|
||||
if (cover) {
|
||||
questionId = idOf.get(cover.id) as string
|
||||
} else {
|
||||
questionId = isUuid(top.questionId) ? (top.questionId as string) : isUuid(bottom.questionId) ? (bottom.questionId as string) : newDomainId()
|
||||
questions.push({ id: questionId, label, order: questions.length, max_marks: 0, is_container: true, mark_scheme: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) })
|
||||
emittedIds.add(questionId)
|
||||
}
|
||||
const qNum = bands.length + 1
|
||||
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
|
||||
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
|
||||
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
||||
bands.push({ questionId, top, bottom })
|
||||
for (const b of [top, bottom]) {
|
||||
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: persistedSource(b), confirmed: persistedConfirmed(b), confidence: persistedConfidence(b), derivation: persistedDerivation(b) })
|
||||
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: 'manual', confirmed: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a structural shape's parent: the persisted parent (if still emitted) wins — this is what keeps a
|
||||
// 3-deep subpart→part→question chain intact across a save. Manually-drawn shapes with no persisted parent
|
||||
// fall back to geometry: the deepest container box that contains them, else the boundary band around them.
|
||||
const resolveParent = (s: ExamCanvasShapeModel): string | null => {
|
||||
if (isUuid(s.parentId) && emittedIds.has(s.parentId as string)) return s.parentId as string
|
||||
const containing = containerShapes
|
||||
.filter((c) => c.id !== s.id && contains(bounds(c), bounds(s)))
|
||||
.sort((a, b) => (b.depth ?? 0) - (a.depth ?? 0) || (a.w * a.h) - (b.w * b.h))
|
||||
if (containing.length) return idOf.get(containing[0].id) ?? null
|
||||
const band = bands.find((bd) => bandContains(bd.top, bd.bottom, s))
|
||||
return band?.questionId ?? null
|
||||
}
|
||||
|
||||
const partQuestionIds = new Map<string, string>()
|
||||
const structuralSorted = [...structural].sort((a, b) => (a.depth ?? 0) - (b.depth ?? 0) || (a.y - b.y) || (a.x - b.x))
|
||||
structuralSorted.forEach((part, index) => {
|
||||
const qid = idOf.get(part.id) as string
|
||||
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
|
||||
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
||||
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
||||
partQuestionIds.set(part.id, qid)
|
||||
questions.push({ id: qid, parent_id: resolveParent(part), label: part.label || (part.isContainer ? `Q${index + 1}` : `Part ${index + 1}`), order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.isContainer ? null : (part.answerType ?? 'written'), mcq_options: null, mark_scheme: {}, is_container: !!part.isContainer, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
|
||||
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages) })
|
||||
})
|
||||
const parts = structural
|
||||
|
||||
// Resolve each region's owner question. Order: current geometric containment (a user who drags a
|
||||
// region into a part re-attaches it) → the PERSISTED attachment if it still points at a saved
|
||||
// question (survives pixel-overflow drift and regions with no enclosing part) → nearest part on the
|
||||
// page → first part → first question of any kind. exam_response_areas.question_id is NOT NULL, so we
|
||||
// never silently drop a region while any question exists (only a template with zero questions can).
|
||||
const questionIds = new Set(questions.map((q) => q.id))
|
||||
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||
// Prefer the DEEPEST (smallest) containing structural box so a region lands on its leaf part, not the
|
||||
// enclosing container. Leaf parts win over containers at equal containment.
|
||||
const deepestContainer = (region: ExamCanvasShapeModel): ExamCanvasShapeModel | undefined =>
|
||||
parts
|
||||
.filter((part) => part.id !== region.id && contains(bounds(part), bounds(region)))
|
||||
.sort((a, b) => Number(!!a.isContainer) - Number(!!b.isContainer) || (a.w * a.h) - (b.w * b.h))[0]
|
||||
for (const region of regions) {
|
||||
const containingPart = deepestContainer(region)
|
||||
const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined
|
||||
const nearestLeaf = parts.filter((p) => !p.isContainer).find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts.find((p) => !p.isContainer) ?? parts[0]
|
||||
const questionId =
|
||||
(containingPart && partQuestionIds.get(containingPart.id))
|
||||
|| persisted
|
||||
|| (nearestLeaf && partQuestionIds.get(nearestLeaf.id))
|
||||
|| questions[0]?.id
|
||||
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||
const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||
if (!questionId) continue
|
||||
const kind = region.kind as ExamCanvasRegionKind
|
||||
// Carry rich recognition detail through the save so it survives a round-trip (the replace endpoint
|
||||
// persists exam_response_areas.meta): answer components / MC boxes / final-answer for responses, and the
|
||||
// figure name + caption for context regions.
|
||||
const carried = (region.meta && typeof region.meta === 'object') ? region.meta as Record<string, unknown> : undefined
|
||||
const meta = kind === 'context' && (region.label || region.description)
|
||||
? { ...(carried ?? {}), name: region.label || carried?.name, description: region.description || carried?.description }
|
||||
: carried
|
||||
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, meta, source: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) })
|
||||
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
|
||||
}
|
||||
|
||||
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] }
|
||||
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
||||
}
|
||||
|
||||
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||
const shapes: ExamCanvasShapeModel[] = []
|
||||
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||
const depthOf = (id: string | null | undefined, guard = 0): number => {
|
||||
const q = id ? questions.get(id) : undefined
|
||||
return !q || !q.parent_id || guard > 8 ? 0 : 1 + depthOf(q.parent_id, guard + 1)
|
||||
}
|
||||
for (const b of detail.boundaries ?? []) {
|
||||
const page = pageGeometry((b.page_index ?? 0) + 1, pages)
|
||||
// Boundary rows are y-lines. The old bounds rect is vestigial: keep y/domain ids,
|
||||
// but render and save a full rendered-page-width horizontal rule.
|
||||
shapes.push({ id: b.id, kind: 'boundary', x: page.x, y: Number(b.y), w: page.w, h: 8, label: b.label ?? undefined, questionId: b.question_id, source: b.source, confirmed: b.confirmed, confidence: b.confidence, derivation: b.derivation })
|
||||
shapes.push({ id: b.id, kind: 'boundary', x: page.x, y: Number(b.y), w: page.w, h: 8, label: b.label ?? undefined, questionId: b.question_id })
|
||||
}
|
||||
// Draw the WHOLE tree that has geometry: containers (main questions / intermediate parts) AND leaf parts,
|
||||
// each carrying its parent id + depth so nesting renders and a save preserves the N-deep chain. A question
|
||||
// with no bounds still can't be drawn (legacy data) — it falls back to boundary-band synthesis on save.
|
||||
for (const q of detail.questions ?? []) {
|
||||
if (!q.bounds) continue
|
||||
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.is_container ? undefined : ((q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written'), questionId: q.id, parentId: q.parent_id, isContainer: q.is_container, depth: depthOf(q.id), source: q.source, confirmed: q.confirmed, confidence: q.confidence, derivation: q.derivation })
|
||||
if (q.is_container || !q.bounds) continue
|
||||
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
||||
}
|
||||
for (const r of detail.response_areas ?? []) {
|
||||
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
||||
const q = questions.get(r.question_id)
|
||||
const meta = ((r as { meta?: Record<string, unknown> }).meta) ?? {}
|
||||
const name = typeof meta.name === 'string' ? meta.name : undefined
|
||||
const description = typeof meta.description === 'string' ? meta.description : undefined
|
||||
// A context region is NAMED by its figure/table (was an anonymous purple box). Response/other regions keep
|
||||
// the "→ owning question" label so their link is legible.
|
||||
const label = r.kind === 'context'
|
||||
? (name || (r.context_type && r.context_type !== 'generic' ? r.context_type : 'Context'))
|
||||
: (q ? `→ ${q.label}` : r.kind)
|
||||
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label, description, linkLabel: q?.label, meta: (r as { meta?: Record<string, unknown> }).meta ?? undefined, responseForm: (r.response_form as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id, source: r.source, confirmed: r.confirmed, confidence: r.confidence, derivation: r.derivation })
|
||||
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
||||
}
|
||||
return addCheapReviewFlags(shapes, pages)
|
||||
}
|
||||
|
||||
function overlaps(a: ExamCanvasShapeModel, b: ExamCanvasShapeModel): boolean {
|
||||
const ax2 = a.x + a.w
|
||||
const ay2 = a.y + a.h
|
||||
const bx2 = b.x + b.w
|
||||
const by2 = b.y + b.h
|
||||
return a.x < bx2 && ax2 > b.x && a.y < by2 && ay2 > b.y
|
||||
}
|
||||
|
||||
function looksUncertainLabel(label: string | undefined): boolean {
|
||||
return !label || /\b(unknown|uncertain|maybe|todo|tbd)\b|\?/.test(label.toLowerCase())
|
||||
}
|
||||
|
||||
function addCheapReviewFlags(shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||
const markAreasByQuestion = new Set(shapes.filter((shape) => shape.kind === 'mark_area' && shape.questionId).map((shape) => shape.questionId as string))
|
||||
return shapes.map((shape, index) => {
|
||||
const flags: string[] = []
|
||||
if (shape.source === 'ai' && shape.confirmed === false) flags.push('unconfirmed AI')
|
||||
if (typeof shape.confidence === 'number' && shape.confidence < 0.7) flags.push('low confidence')
|
||||
if ((shape.kind === 'part' || shape.kind === 'question_number') && looksUncertainLabel(shape.label)) flags.push('uncertain question label')
|
||||
if (shape.kind === 'part' && (!shape.maxMarks || shape.maxMarks <= 0) && !markAreasByQuestion.has(shape.questionId ?? shape.id)) flags.push('missing marks')
|
||||
// Containers are MEANT to enclose their children, so never flag them (or their children) for overlap;
|
||||
// flag only genuine partial overlaps between non-container shapes where neither contains the other.
|
||||
const samePageOverlap = !shape.isContainer && shape.kind !== 'boundary' && shapes.some((other, otherIndex) => otherIndex !== index && !other.isContainer && other.kind !== 'boundary' && pageForShape(shape, pages) === pageForShape(other, pages) && overlaps(shape, other) && !contains(bounds(shape), bounds(other)) && !contains(bounds(other), bounds(shape)))
|
||||
if (samePageOverlap) flags.push('overlapping shapes')
|
||||
return flags.length ? { ...shape, reviewFlags: flags } : shape
|
||||
})
|
||||
return shapes
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user