Compare commits
6 Commits
7bd66fbaf0
...
b52f6c54ba
| Author | SHA1 | Date | |
|---|---|---|---|
| b52f6c54ba | |||
| e6a2480818 | |||
| 2b33aa80eb | |||
|
|
7a1dd4dccc | ||
|
|
4001772d5e | ||
|
|
f72ea7189c |
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { v5 as uuidv5 } from 'uuid';
|
import { v5 as uuidv5 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
@ -21,6 +21,7 @@ import {
|
|||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||||
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
|
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
||||||
@ -43,6 +44,8 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = 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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!batchId) return;
|
if (!batchId) return;
|
||||||
@ -68,6 +71,28 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
||||||
[template],
|
[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 selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
||||||
|
|
||||||
const saveSelected = async () => {
|
const saveSelected = async () => {
|
||||||
@ -154,7 +179,29 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||||
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>Marking queue</Typography>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
|
||||||
|
<Typography variant="h6">Marking queue</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFileIcon fontSize="small" />}
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading…' : 'Upload scans'}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => { void onScanFiles(e.target.files); }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||||
|
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
|
||||||
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
<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.total ?? 0} total`} />
|
||||||
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
|||||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
||||||
import SaveIcon from '@mui/icons-material/Save'
|
import SaveIcon from '@mui/icons-material/Save'
|
||||||
import MouseIcon from '@mui/icons-material/Mouse'
|
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 '@tldraw/tldraw/tldraw.css'
|
||||||
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
@ -82,6 +85,21 @@ function reviewSummary(template: ExamTemplateDetail | null) {
|
|||||||
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
}, { 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) {
|
function stripShapePrefix(id: string) {
|
||||||
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||||
}
|
}
|
||||||
@ -205,8 +223,11 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
const [guideOpen, setGuideOpen] = useState(false)
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
||||||
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
||||||
|
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||||
const autoMapPollRef = useRef<number | null>(null)
|
const autoMapPollRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
|
||||||
|
|
||||||
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
||||||
setTemplate(detail)
|
setTemplate(detail)
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
@ -219,7 +240,33 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
setDirty(false)
|
setDirty(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const review = useMemo(() => reviewSummary(template), [template])
|
const acceptShapes = useCallback((shapes: TLShape[]) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const updates = shapes
|
||||||
|
.filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false)
|
||||||
|
.map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } }))
|
||||||
|
if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) }
|
||||||
|
}, [refreshReview])
|
||||||
|
|
||||||
|
const selectedDomainShapes = useCallback((): TLShape[] => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return []
|
||||||
|
return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes])
|
||||||
|
const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes])
|
||||||
|
const rejectSelected = useCallback(() => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const ids = selectedDomainShapes().map((s) => s.id)
|
||||||
|
if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) }
|
||||||
|
}, [refreshReview, selectedDomainShapes])
|
||||||
|
|
||||||
|
// Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the
|
||||||
|
// saved-template summary only for the brief moment before shapes have mounted).
|
||||||
|
const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
@ -416,7 +463,23 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
editorRef.current = editor
|
editorRef.current = editor
|
||||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
editor.store.listen(() => { setDirty(true); refreshReview() }, { scope: 'document' })
|
||||||
|
// Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who
|
||||||
|
// adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only
|
||||||
|
// update below can't recurse.
|
||||||
|
editor.store.listen((entry) => {
|
||||||
|
try {
|
||||||
|
const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = []
|
||||||
|
for (const rec of Object.values(entry.changes.updated)) {
|
||||||
|
const [from, to] = rec as [any, any]
|
||||||
|
if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue
|
||||||
|
if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue
|
||||||
|
const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h
|
||||||
|
if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } })
|
||||||
|
}
|
||||||
|
if (updates.length) editor.updateShapes(updates as any)
|
||||||
|
} catch { /* non-fatal: implicit-confirm is best-effort */ }
|
||||||
|
}, { scope: 'document', source: 'user' })
|
||||||
applyDocViewConstraints(editor, [])
|
applyDocViewConstraints(editor, [])
|
||||||
editor.resetZoom()
|
editor.resetZoom()
|
||||||
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
||||||
@ -432,6 +495,22 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</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 */}
|
{/* Guide toggle */}
|
||||||
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
<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' } }}>
|
<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' } }}>
|
||||||
|
|||||||
@ -81,6 +81,12 @@ function confidenceLabel(confidence: number | null | undefined) {
|
|||||||
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
|
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[] {
|
function reviewFlags(shape: ExamCanvasTLShape): string[] {
|
||||||
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
|
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
@ -100,16 +106,17 @@ function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
|||||||
const p = canvasShapePalette.boundary
|
const p = canvasShapePalette.boundary
|
||||||
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
||||||
const isAi = shape.props.source === 'ai'
|
const isAi = shape.props.source === 'ai'
|
||||||
|
const isGhost = isAi && shape.props.confirmed === false
|
||||||
const confidence = confidenceLabel(shape.props.confidence)
|
const confidence = confidenceLabel(shape.props.confidence)
|
||||||
const flags = reviewFlags(shape)
|
const flags = reviewFlags(shape)
|
||||||
return (
|
return (
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
||||||
<style>{shapeCss}</style>
|
<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' }}>
|
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
||||||
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isAi ? '4 6' : p.dash} strokeLinecap="round" opacity={isAi ? 0.62 : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
||||||
</svg>
|
</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 className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
||||||
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
<span aria-hidden="true">{isGhost ? 'AI' : p.icon}</span>
|
||||||
{shape.props.label || p.label}
|
{shape.props.label || p.label}
|
||||||
</span>
|
</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>}
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
@ -139,8 +146,8 @@ function renderShape(shape: ExamCanvasTLShape) {
|
|||||||
'--exam-dark-stroke': p.darkStroke,
|
'--exam-dark-stroke': p.darkStroke,
|
||||||
'--exam-dark-fill': p.darkFill,
|
'--exam-dark-fill': p.darkFill,
|
||||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||||
borderStyle: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
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,
|
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}
|
} as React.CSSProperties}
|
||||||
@ -148,7 +155,7 @@ function renderShape(shape: ExamCanvasTLShape) {
|
|||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
<span aria-hidden="true">{isAiSuggestion ? 'AI' : p.icon}</span>
|
||||||
{shape.props.label || p.label}
|
{shape.props.label || p.label}
|
||||||
</span>
|
</span>
|
||||||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import type {
|
|||||||
Neo4jSyncResult,
|
Neo4jSyncResult,
|
||||||
PatchQuestionPayload,
|
PatchQuestionPayload,
|
||||||
SpecPoint,
|
SpecPoint,
|
||||||
|
StudentSubmission,
|
||||||
TemplateReplacePayload,
|
TemplateReplacePayload,
|
||||||
UpdateTemplateMetaPayload,
|
UpdateTemplateMetaPayload,
|
||||||
} from '../../types/exam.types';
|
} from '../../types/exam.types';
|
||||||
@ -298,6 +299,21 @@ export const examRepository = {
|
|||||||
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||||
return res.data;
|
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;
|
export default examRepository;
|
||||||
|
|||||||
@ -92,4 +92,28 @@ describe('exam setup canvas serialization', () => {
|
|||||||
expect(payload.boundaries.find((b) => b.id === '44444444-4444-4444-8444-444444444444')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
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('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
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -147,11 +147,22 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
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), 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), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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'] = []
|
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||||
for (const region of regions) {
|
for (const region of regions) {
|
||||||
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||||
const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined
|
||||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
const nearestPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
||||||
|
const questionId =
|
||||||
|
(containingPart && partQuestionIds.get(containingPart.id))
|
||||||
|
|| persisted
|
||||||
|
|| (nearestPart && partQuestionIds.get(nearestPart.id))
|
||||||
|
|| questions[0]?.id
|
||||||
if (!questionId) continue
|
if (!questionId) continue
|
||||||
const kind = region.kind as ExamCanvasRegionKind
|
const kind = region.kind as ExamCanvasRegionKind
|
||||||
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: 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: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) })
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user