Compare commits
No commits in common. "dbab592ce8816550864c077601785baf59010217" and "e6ecc723cc59d8cc9f81d7c51c54d7aa1b991aeb" have entirely different histories.
dbab592ce8
...
e6ecc723cc
@ -1,8 +1,7 @@
|
|||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, 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 { Alert, Box, Button, Chip, CircularProgress, Collapse, Dialog, DialogContent, DialogTitle, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
import { Alert, Box, Button, Chip, CircularProgress, Collapse, 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 ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
||||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
||||||
@ -18,7 +17,7 @@ import axios from 'axios'
|
|||||||
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||||
import { logger } from '../../../debugConfig'
|
import { logger } from '../../../debugConfig'
|
||||||
import { examRepository } from '../../../services/exam/examRepository'
|
import { examRepository } from '../../../services/exam/examRepository'
|
||||||
import type { AutoMapJobStatus, DigitalTextResponse, 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 { 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 { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||||
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||||
@ -225,25 +224,8 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
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 [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 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 refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
|
||||||
|
|
||||||
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
||||||
@ -457,11 +439,6 @@ 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={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'} />
|
<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)} />}
|
{(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="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>
|
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
@ -575,32 +552,6 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></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>
|
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import type {
|
|||||||
AutoMapResponse,
|
AutoMapResponse,
|
||||||
BankResponse,
|
BankResponse,
|
||||||
CorpusResponse,
|
CorpusResponse,
|
||||||
DigitalTextResponse,
|
|
||||||
BatchQueueResponse,
|
BatchQueueResponse,
|
||||||
BatchResultsResponse,
|
BatchResultsResponse,
|
||||||
CreateBatchPayload,
|
CreateBatchPayload,
|
||||||
@ -165,12 +164,6 @@ export const examRepository = {
|
|||||||
return res.data.templates ?? [];
|
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> {
|
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||||
|
|||||||
@ -396,13 +396,3 @@ export interface CorpusResponse {
|
|||||||
totals: { specs: number; papers: number; sessions: number; QP: number; MS: number; ER: number };
|
totals: { specs: number; papers: number; sessions: number; QP: number; MS: number; ER: number };
|
||||||
boards: CorpusBoard[];
|
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 }[];
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user