Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
900e2613b4 | ||
|
|
c346493a34 | ||
|
|
81e1b5ed25 | ||
|
|
e0e36225fe | ||
|
|
dbab592ce8 | ||
|
|
0c46582ec3 |
@@ -1,7 +1,8 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Dialog, DialogContent, DialogTitle, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||
import DescriptionIcon from '@mui/icons-material/Description'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
||||
@@ -17,7 +18,7 @@ import axios from 'axios'
|
||||
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||
import { logger } from '../../../debugConfig'
|
||||
import { examRepository } from '../../../services/exam/examRepository'
|
||||
import type { AutoMapJobStatus, ExamTemplateDetail } from '../../../types/exam.types'
|
||||
import type { AutoMapJobStatus, DigitalTextResponse, ExamTemplateDetail } from '../../../types/exam.types'
|
||||
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||
@@ -135,6 +136,12 @@ 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,
|
||||
@@ -147,16 +154,28 @@ 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[]) {
|
||||
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||
if (existing.length) editor.deleteShapes(existing)
|
||||
if (!models.length) return
|
||||
editor.createShapes(models.map((m) => ({
|
||||
const ordered = [...models].sort((a, b) => zPriority(a) - zPriority(b))
|
||||
editor.createShapes(ordered.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, 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, 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('|') },
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -224,8 +243,25 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
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) => {
|
||||
@@ -439,6 +475,11 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
|
||||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
|
||||
{(() => {
|
||||
const a = (template as (ExamTemplateDetail & { extraction_meta?: { audit?: { status?: string; cover_total?: number; detected_total?: number } } }) | null)?.extraction_meta?.audit
|
||||
return a?.status ? <Tooltip title="Extraction cover-total reconciliation (detected vs printed maximum)"><Chip size="small" color={a.status === 'ok' ? 'success' : 'warning'} label={`Marks ${a.detected_total ?? '?'}/${a.cover_total ?? '?'}`} /></Tooltip> : null
|
||||
})()}
|
||||
<Button size="small" variant="outlined" startIcon={digitalBusy ? <CircularProgress size={14} /> : <DescriptionIcon fontSize="small" />} onClick={openDigitalText} disabled={digitalBusy || !template}>Digital text</Button>
|
||||
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
|
||||
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||
</Paper>
|
||||
@@ -552,6 +593,32 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
|
||||
<Dialog open={digitalOpen} onClose={() => setDigitalOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DescriptionIcon fontSize="small" />
|
||||
Digital text
|
||||
{digitalText && <Typography component="span" variant="body2" color="text.secondary">— {digitalText.title} · {digitalText.n_questions}Q · {digitalText.total_marks} marks</Typography>}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{digitalText && (
|
||||
<Tooltip title="Download markdown">
|
||||
<IconButton size="small" onClick={() => {
|
||||
const blob = new Blob([digitalText.markdown], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob); const a = document.createElement('a')
|
||||
a.href = url; a.download = `${digitalText.slug}.md`; a.click(); URL.revokeObjectURL(url)
|
||||
}}><SaveIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{digitalBusy ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||
) : digitalText ? (
|
||||
<Box component="pre" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 13, m: 0 }}>{digitalText.markdown}</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">No digital text available.</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -35,6 +35,12 @@ 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
|
||||
@@ -137,45 +143,132 @@ function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
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 isAi = shape.props.source === 'ai'
|
||||
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 title = provenanceTitle(shape, `${p.label}: ${p.role}`)
|
||||
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: 'all' }}>
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: isPreamble ? 'none' : 'all' }}>
|
||||
<style>{shapeCss}</style>
|
||||
<div
|
||||
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
|
||||
className={`exam-canvas-shape exam-canvas-shape--${kind}${isContainer ? ' exam-canvas-shape--container' : ''}`}
|
||||
style={{
|
||||
'--exam-light-stroke': p.stroke,
|
||||
'--exam-light-stroke': stroke,
|
||||
'--exam-light-fill': p.fill,
|
||||
'--exam-dark-stroke': p.darkStroke,
|
||||
'--exam-dark-stroke': darkStroke,
|
||||
'--exam-dark-fill': p.darkFill,
|
||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||
borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||||
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,
|
||||
} as React.CSSProperties}
|
||||
aria-label={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 aria-hidden="true">{isAiSuggestion ? 'AI' : p.icon}</span>
|
||||
{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>
|
||||
{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 && !isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</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 && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
@@ -186,7 +279,7 @@ function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, source: 'manual' as const, confirmed: true }
|
||||
}
|
||||
|
||||
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), 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), 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 ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||||
|
||||
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { ResultsWidget } from '../exam';
|
||||
import MarkbookPanel from './MarkbookPanel';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
@@ -270,6 +271,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
{/* Tabs */}
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tab label={`Students (${cls.student_count})`} />
|
||||
<Tab label="Markbook" />
|
||||
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
|
||||
<Tab label={`Teachers (${cls.teachers.length})`} />
|
||||
</Tabs>
|
||||
@@ -332,8 +334,13 @@ const ClassDetailPage: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Enrollment requests tab */}
|
||||
{/* Markbook tab */}
|
||||
{tab === 1 && (
|
||||
<MarkbookPanel classId={cls.id} accessToken={accessToken || ''} />
|
||||
)}
|
||||
|
||||
{/* Enrollment requests tab */}
|
||||
{tab === 2 && (
|
||||
<Box>
|
||||
{cls.enrollment_requests.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
@@ -392,7 +399,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Teachers tab */}
|
||||
{tab === 2 && (
|
||||
{tab === 3 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{cls.teachers.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
import { API_BASE } from '../../config/apiConfig';
|
||||
|
||||
interface Assessment {
|
||||
id: string;
|
||||
title: string;
|
||||
date?: string | null;
|
||||
max_marks: number;
|
||||
}
|
||||
|
||||
interface MarkbookStudent {
|
||||
student_id: string;
|
||||
student_name: string;
|
||||
row_number: number;
|
||||
marks: Record<string, number | null>;
|
||||
total: number | null;
|
||||
percentage: number | null;
|
||||
}
|
||||
|
||||
interface AssessmentSummary {
|
||||
assessment_id: string;
|
||||
entered_count: number;
|
||||
average_mark: number | null;
|
||||
average_percentage: number | null;
|
||||
}
|
||||
|
||||
interface MarkbookGrid {
|
||||
assessments: Assessment[];
|
||||
students: MarkbookStudent[];
|
||||
assessment_summaries: AssessmentSummary[];
|
||||
summary: {
|
||||
student_count: number;
|
||||
assessment_count: number;
|
||||
entered_mark_count: number;
|
||||
class_average_mark: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface MarkbookPanelProps {
|
||||
classId: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
const MARKBOOK_BASE = `${API_BASE}/api/markbook`;
|
||||
|
||||
function formatNumber(value: number | null | undefined) {
|
||||
return value === null || value === undefined ? '—' : Number(value).toFixed(Number.isInteger(value) ? 0 : 1);
|
||||
}
|
||||
|
||||
function markInputValue(value: number | null | undefined) {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
function AddAssessmentDialog({
|
||||
open,
|
||||
saving,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (payload: { title: string; date?: string; max_marks: number }) => Promise<void>;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [maxMarks, setMaxMarks] = useState('100');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle('');
|
||||
setDate('');
|
||||
setMaxMarks('100');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const parsedMax = Number(maxMarks);
|
||||
if (!title.trim() || !Number.isFinite(parsedMax) || parsedMax <= 0) return;
|
||||
await onCreate({ title: title.trim(), date: date || undefined, max_marks: parsedMax });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Add assessment column</DialogTitle>
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Title" size="small" value={title} onChange={(e) => setTitle(e.target.value)} autoFocus />
|
||||
<TextField label="Date" type="date" size="small" value={date} onChange={(e) => setDate(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
<TextField label="Max marks" type="number" size="small" value={maxMarks} onChange={(e) => setMaxMarks(e.target.value)} inputProps={{ min: 0, step: 0.5 }} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleCreate} variant="contained" disabled={saving || !title.trim()} startIcon={saving ? <CircularProgress size={16} /> : <AddIcon />}>
|
||||
Add
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const MarkbookPanel: React.FC<MarkbookPanelProps> = ({ classId, accessToken }) => {
|
||||
const [grid, setGrid] = useState<MarkbookGrid | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [savingAssessment, setSavingAssessment] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const headers = useMemo(() => ({ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }), [accessToken]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!classId || !accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/grid`, { headers });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.detail || 'Failed to load markbook');
|
||||
setGrid(body);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken, classId, headers]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const createAssessment = async (payload: { title: string; date?: string; max_marks: number }) => {
|
||||
setSavingAssessment(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/assessments`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.detail || 'Failed to create assessment');
|
||||
setAddOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingAssessment(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveMark = async (studentId: string, assessment: Assessment, raw: string) => {
|
||||
const key = `${studentId}:${assessment.id}`;
|
||||
const trimmed = raw.trim();
|
||||
const mark = trimmed === '' ? null : Number(trimmed);
|
||||
if (mark !== null && (!Number.isFinite(mark) || mark < 0 || mark > Number(assessment.max_marks))) {
|
||||
setError(`Mark must be between 0 and ${assessment.max_marks}`);
|
||||
return;
|
||||
}
|
||||
setSavingKey(key);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/assessments/${assessment.id}/marks/${studentId}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ mark }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.detail || 'Failed to save mark');
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCsv = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/csv`, { headers });
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(text || 'Failed to export CSV');
|
||||
const blob = new Blob([text], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `markbook-${classId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (!grid) {
|
||||
return <Alert severity="error">{error || 'Could not load markbook'}</Alert>;
|
||||
}
|
||||
|
||||
const summaryByAssessment = new Map(grid.assessment_summaries.map((s) => [s.assessment_id, s]));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{error && <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }} justifyContent="space-between" alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="body2" color="text.secondary">{grid.summary.student_count} roster students</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{grid.summary.assessment_count} assessments</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Class average mark {formatNumber(grid.summary.class_average_mark)}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="outlined" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={grid.assessments.length === 0}>CSV</Button>
|
||||
<Button size="small" variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>Add assessment</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{grid.assessments.length === 0 ? (
|
||||
<Paper variant="outlined" sx={{ py: 4, px: 2, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>No assessment columns yet.</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>Add first assessment</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 620 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ minWidth: 44 }}>#</TableCell>
|
||||
<TableCell sx={{ minWidth: 180 }}>Student</TableCell>
|
||||
{grid.assessments.map((assessment) => (
|
||||
<TableCell key={assessment.id} align="right" sx={{ minWidth: 130 }}>
|
||||
<Typography variant="body2" fontWeight={700}>{assessment.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{assessment.date ? `${new Date(assessment.date).toLocaleDateString('en-GB')} · ` : ''}/{assessment.max_marks}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right">Total</TableCell>
|
||||
<TableCell align="right">%</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{grid.students.map((student) => (
|
||||
<TableRow key={student.student_id}>
|
||||
<TableCell>{student.row_number}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={600}>{student.student_name || student.student_id}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{student.student_id}</Typography>
|
||||
</TableCell>
|
||||
{grid.assessments.map((assessment) => {
|
||||
const key = `${student.student_id}:${assessment.id}`;
|
||||
return (
|
||||
<TableCell key={assessment.id} align="right">
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
defaultValue={markInputValue(student.marks[assessment.id])}
|
||||
onBlur={(e) => void saveMark(student.student_id, assessment, e.target.value)}
|
||||
disabled={savingKey === key}
|
||||
inputProps={{ min: 0, max: assessment.max_marks, step: 0.5, style: { textAlign: 'right' } }}
|
||||
sx={{ width: 86 }}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCell align="right"><strong>{formatNumber(student.total)}</strong></TableCell>
|
||||
<TableCell align="right">{formatNumber(student.percentage)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableCell />
|
||||
<TableCell><strong>Average</strong></TableCell>
|
||||
{grid.assessments.map((assessment) => {
|
||||
const summary = summaryByAssessment.get(assessment.id);
|
||||
return <TableCell key={assessment.id} align="right"><strong>{formatNumber(summary?.average_mark)}</strong></TableCell>;
|
||||
})}
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<AddAssessmentDialog open={addOpen} saving={savingAssessment} onClose={() => setAddOpen(false)} onCreate={createAssessment} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkbookPanel;
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
AutoMapResponse,
|
||||
BankResponse,
|
||||
CorpusResponse,
|
||||
DigitalTextResponse,
|
||||
BatchQueueResponse,
|
||||
BatchResultsResponse,
|
||||
CreateBatchPayload,
|
||||
@@ -164,6 +165,12 @@ export const examRepository = {
|
||||
return res.data.templates ?? [];
|
||||
},
|
||||
|
||||
async getDigitalText(templateId: string): Promise<DigitalTextResponse> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<DigitalTextResponse>(`${EXAM_BASE}/templates/${templateId}/digital-text`, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
|
||||
@@ -91,6 +91,10 @@ 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;
|
||||
@@ -116,6 +120,8 @@ 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;
|
||||
confirmed: boolean;
|
||||
confidence: number | null;
|
||||
@@ -197,6 +203,7 @@ 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;
|
||||
@@ -396,3 +403,13 @@ 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 }[];
|
||||
}
|
||||
|
||||
@@ -104,6 +104,37 @@ describe('exam setup canvas serialization', () => {
|
||||
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'
|
||||
|
||||
@@ -26,6 +26,19 @@ 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
|
||||
@@ -118,34 +131,69 @@ 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))
|
||||
const parts = shapes.filter((s) => s.kind === 'part')
|
||||
// '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 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 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: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) })
|
||||
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)
|
||||
}
|
||||
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) })
|
||||
}
|
||||
}
|
||||
|
||||
// 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>()
|
||||
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()
|
||||
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
|
||||
partQuestionIds.set(part.id, qid)
|
||||
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: 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) })
|
||||
})
|
||||
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
|
||||
@@ -154,18 +202,31 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
||||
// 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 = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||
const containingPart = deepestContainer(region)
|
||||
const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined
|
||||
const nearestPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
||||
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
|
||||
|| (nearestPart && partQuestionIds.get(nearestPart.id))
|
||||
|| (nearestLeaf && partQuestionIds.get(nearestLeaf.id))
|
||||
|| questions[0]?.id
|
||||
if (!questionId) continue
|
||||
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) })
|
||||
// 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) })
|
||||
}
|
||||
|
||||
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 ?? [] }
|
||||
@@ -174,20 +235,35 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
||||
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 })
|
||||
}
|
||||
// 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.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 as ExamCanvasShapeModel['answerType']) ?? 'written', questionId: q.id, source: q.source, confirmed: q.confirmed, confidence: q.confidence, derivation: q.derivation })
|
||||
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 })
|
||||
}
|
||||
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)
|
||||
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 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 })
|
||||
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 })
|
||||
}
|
||||
return addCheapReviewFlags(shapes, pages)
|
||||
}
|
||||
@@ -212,7 +288,9 @@ function addCheapReviewFlags(shapes: ExamCanvasShapeModel[], pages?: CanvasPageG
|
||||
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')
|
||||
const samePageOverlap = shapes.some((other, otherIndex) => otherIndex !== index && shape.kind !== 'boundary' && other.kind !== 'boundary' && pageForShape(shape, pages) === pageForShape(other, pages) && overlaps(shape, other) && (shape.kind === other.kind || (!contains(bounds(shape), bounds(other)) && !contains(bounds(other), bounds(shape)))))
|
||||
// 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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user