diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index ba08234..bb5ac55 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -4,6 +4,7 @@ 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 ArrowBackIcon from '@mui/icons-material/ArrowBack' import HelpOutlineIcon from '@mui/icons-material/HelpOutline' +import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh' import SaveIcon from '@mui/icons-material/Save' import MouseIcon from '@mui/icons-material/Mouse' import '@tldraw/tldraw/tldraw.css' @@ -13,7 +14,7 @@ import axios from 'axios' import { ErrorBoundary } from '../../../components/ErrorBoundary' import { logger } from '../../../debugConfig' import { examRepository } from '../../../services/exam/examRepository' -import type { ExamTemplateDetail } from '../../../types/exam.types' +import type { AutoMapJobStatus, 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' @@ -34,6 +35,8 @@ const PAGE_START_X = 0 const PDF_PAGE_IDS_PREFIX = 'pdf-page-' function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] { + // S5 coordinate contract: use the actual pdf.js raster dimensions that feed each page src. + // Server mapper emits canvas coordinates against the same PAGE_START_X=0 and stacked page heights. let y = 0 return pages.map((page) => { const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height } @@ -102,6 +105,10 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null { responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'], contextType: s.props.contextType, questionId: s.props.questionId ?? null, + source: s.props.source, + confirmed: s.props.confirmed, + confidence: s.props.confidence ?? null, + derivation: s.props.derivation ?? null, } } @@ -111,15 +118,15 @@ function bringDomainShapesToFront(editor: Editor) { } function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) { - if (!models.length) return const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id) if (existing.length) editor.deleteShapes(existing) + if (!models.length) return editor.createShapes(models.map((m) => ({ id: createShapeId(m.id), type: SHAPE_TYPES[m.kind], x: m.x, y: m.y, - props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id }, + 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, confirmed: m.confirmed, confidence: m.confidence, derivation: m.derivation }, }))) } @@ -154,6 +161,20 @@ function seedGuide(editor: Editor) { ]) } + +function isAutoMapAccepted(value: unknown): value is { status: 'accepted'; job_id: string } { + return !!value && typeof value === 'object' && (value as { status?: string }).status === 'accepted' && typeof (value as { job_id?: unknown }).job_id === 'string' +} + +function autoMapStatusLabel(status: AutoMapJobStatus | null): string { + if (!status) return 'Auto-map running' + if (status.status === 'queued') return 'Auto-map queued' + if (status.status === 'running') return 'Auto-map running' + if (status.status === 'completed') return 'Auto-map complete' + if (status.status === 'failed') return 'Auto-map failed' + return `Auto-map ${status.status}` +} + const ExamTemplateSetupInner: React.FC = () => { const { templateId } = useParams<{ templateId: string }>() const navigate = useNavigate() @@ -170,6 +191,19 @@ const ExamTemplateSetupInner: React.FC = () => { const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading') const [pdfError, setPdfError] = useState(null) const [guideOpen, setGuideOpen] = useState(false) + const [autoMapStatus, setAutoMapStatus] = useState(null) + const [autoMapBusy, setAutoMapBusy] = useState(false) + const autoMapPollRef = useRef(null) + + const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => { + setTemplate(detail) + const editor = editorRef.current + if (editor) { + loadShapes(editor, shapesFromTemplate(detail, pageGeometriesRef.current)) + bringDomainShapesToFront(editor) + } + setDirty(false) + }, []) const load = useCallback(async () => { if (!templateId) return @@ -227,6 +261,62 @@ const ExamTemplateSetupInner: React.FC = () => { useEffect(() => { void load() }, [load]) + useEffect(() => () => { + if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current) + }, []) + + const pollAutoMapStatus = useCallback(async (jobId: string) => { + if (!templateId) return + try { + const status = await examRepository.getAutoMapStatus(templateId, jobId) + setAutoMapStatus(status) + if (status.status === 'completed') { + if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current) + autoMapPollRef.current = null + setAutoMapBusy(false) + const detail = status.template ?? await examRepository.getTemplate(templateId) + applyTemplateToCanvas(detail) + return + } + if (status.status === 'failed') { + if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current) + autoMapPollRef.current = null + setAutoMapBusy(false) + setError(status.error ?? 'Auto-map failed; existing template state was preserved.') + return + } + autoMapPollRef.current = window.setTimeout(() => void pollAutoMapStatus(jobId), 2500) + } catch (e) { + const msg = apiMessage(e).message + setAutoMapBusy(false) + setError(msg) + logger.warn('cc-exam-marker', 'Auto-map status poll failed', { templateId, jobId, message: msg }) + } + }, [applyTemplateToCanvas, templateId]) + + const autoMapFromPdf = useCallback(async () => { + if (!templateId || autoMapBusy) return + let queued = false + setAutoMapBusy(true); setAutoMapStatus(null); setError(null); setConflict(null) + try { + const result = await examRepository.autoMapTemplate(templateId) + if (isAutoMapAccepted(result)) { + queued = true + setAutoMapStatus({ job_id: result.job_id, status: 'queued', template_id: templateId }) + await pollAutoMapStatus(result.job_id) + return + } + setAutoMapStatus(null) + applyTemplateToCanvas(result) + } catch (e) { + const msg = apiMessage(e) + if (msg.conflict) setConflict(msg.message); else setError(msg.message) + logger.warn('cc-exam-marker', 'Auto-map request failed', { templateId, message: msg.message }) + } finally { + if (!queued) setAutoMapBusy(false) + } + }, [applyTemplateToCanvas, autoMapBusy, pollAutoMapStatus, templateId]) + const save = useCallback(async () => { const editor = editorRef.current if (!editor || !templateId || !template) return @@ -248,6 +338,11 @@ const ExamTemplateSetupInner: React.FC = () => { } }, [template, templateId]) + const layoutSummary = useMemo(() => { + const rows = (template?.layout ?? []).filter((row) => row.margins_enabled && row.margin_left !== null && row.margin_right !== null) + return rows.slice(0, 4).map((row) => `P${row.page_index + 1} ${row.role ?? 'page'} L${Math.round(row.margin_left ?? 0)} R${Math.round(row.margin_right ?? 0)} T${Math.round(row.margin_top ?? 0)} B${Math.round(row.margin_bottom ?? 0)}`) + }, [template?.layout]) + const toolButtons = useMemo(() => TOOLS.map((tool) => ( @@ -337,6 +434,9 @@ const ExamTemplateSetupInner: React.FC = () => { PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'} + + Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'} + diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index 7c36bb3..7d76056 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -4,6 +4,7 @@ import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T import type { TLHandle } from '@tldraw/tldraw' import { PAGE_WIDTH } from '../../../utils/exam-canvas/model' import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' +import type { ExamTemplateSource } from '../../../types/exam.types' export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page' @@ -35,6 +36,10 @@ export type ExamCanvasTLShape = TLBaseBoxShape & { contextType?: string questionId?: string | null domainId?: string + source?: ExamTemplateSource + confirmed?: boolean + confidence?: number | null + derivation?: string | null } } @@ -74,11 +79,11 @@ function renderBoundaryLine(shape: ExamCanvasTLShape) { - + - {shape.props.label || p.label} + {shape.props.source === 'ai' && shape.props.confirmed === false ? 'AI · ' : ''}{shape.props.label || p.label} ) @@ -88,6 +93,7 @@ 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) return ( @@ -100,8 +106,8 @@ function renderShape(shape: ExamCanvasTLShape) { '--exam-dark-stroke': p.darkStroke, '--exam-dark-fill': p.darkFill, width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`, - borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10, - background: isBoundary ? 'transparent' : 'var(--exam-fill)', color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif', + borderStyle: isAiSuggestion || p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10, + background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? 0.58 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif', display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between', padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6, } as React.CSSProperties} @@ -110,7 +116,7 @@ function renderShape(shape: ExamCanvasTLShape) { > - {shape.props.label || p.label} + {isAiSuggestion ? 'AI · ' : ''}{shape.props.label || p.label} {!isBoundary && shape.props.questionId && Attached} {isBoundary && pair across pages} @@ -124,7 +130,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 } } -const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) } +const 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) } const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => class PdfPageUtil extends BaseBoxShapeUtil { diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index 535b821..709b425 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -11,6 +11,8 @@ import { API_BASE } from '../../config/apiConfig'; import { logger } from '../../debugConfig'; import { supabase } from '../../supabaseClient'; import type { + AutoMapJobStatus, + AutoMapResponse, BatchQueueResponse, BatchResultsResponse, CreateBatchPayload, @@ -163,6 +165,18 @@ export const examRepository = { return res.data; }, + async autoMapTemplate(templateId: string): Promise { + const headers = await authHeaders(); + const res = await axios.post(`${EXAM_BASE}/templates/${templateId}/auto-map`, {}, { headers }); + return res.data; + }, + + async getAutoMapStatus(templateId: string, jobId: string): Promise { + const headers = await authHeaders(); + const res = await axios.get(`${EXAM_BASE}/templates/${templateId}/auto-map/${jobId}/status`, { headers }); + return res.data; + }, + async getTemplateSourcePdf(templateId: string): Promise { const headers = await authHeaders(); const res = await axios.get(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 17d08e9..b869de3 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -257,6 +257,23 @@ export interface Neo4jSyncResult { projection?: Record; } +export interface AutoMapAcceptedResponse { + status: 'accepted'; + job_id: string; +} + +export interface AutoMapJobStatus { + job_id: string; + status: 'queued' | 'running' | 'completed' | 'failed' | string; + template_id: string; + updated_at?: number; + counts?: Record; + error?: string; + template?: ExamTemplateDetail; +} + +export type AutoMapResponse = ExamTemplateDetail | AutoMapAcceptedResponse; + export interface MarkingBatch { id: string; template_id: string; diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts index 7d46792..acf6d15 100644 --- a/src/utils/exam-canvas/model.ts +++ b/src/utils/exam-canvas/model.ts @@ -1,5 +1,5 @@ -import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types' +import type { ExamTemplateDetail, ExamTemplateSource, TemplateReplacePayload } from '../../types/exam.types' export const PAGE_HEIGHT = 1100 export const PAGE_WIDTH = 780 @@ -26,6 +26,10 @@ export interface ExamCanvasShapeModel { responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks' contextType?: string questionId?: string | null + source?: ExamTemplateSource + confirmed?: boolean + confidence?: number | null + derivation?: string | null } export function pageForY(y: number, pages?: CanvasPageGeometry[]): number { @@ -111,10 +115,10 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam 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: 'manual', confirmed: true, confidence: null, derivation: null }) + questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {}, source: top.source ?? bottom.source ?? 'manual', confirmed: top.confirmed ?? bottom.confirmed ?? true, confidence: top.confidence ?? bottom.confidence ?? null, derivation: top.derivation ?? bottom.derivation ?? null }) 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: 'manual', confirmed: true, confidence: null, derivation: null }) + 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: b.source ?? 'manual', confirmed: b.confirmed ?? true, confidence: b.confidence ?? null, derivation: b.derivation ?? null }) } } @@ -123,7 +127,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part)) const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId() partQuestionIds.set(part.id, qid) - questions.push({ id: qid, parent_id: 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: 'manual', confirmed: true, confidence: null, derivation: null }) + 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: part.source ?? 'manual', confirmed: part.confirmed ?? true, confidence: part.confidence ?? null, derivation: part.derivation ?? null }) }) const response_areas: TemplateReplacePayload['response_areas'] = [] @@ -133,7 +137,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined 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: 'manual', confirmed: true, confidence: null, mark_subtype: null, derivation: null }) + 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: region.source ?? 'manual', confirmed: region.confirmed ?? true, confidence: region.confidence ?? null, mark_subtype: null, derivation: region.derivation ?? null }) } return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] } @@ -146,16 +150,16 @@ export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPag 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 }) + 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 }) } 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 }) + 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 }) } 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 }) + 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 }) } return shapes }