Compare commits
17 Commits
fix/canvas
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bd66fbaf0 | ||
| 9c3a5f97cc | |||
|
|
2ccfb9ccd6 | ||
|
|
ef13a124dd | ||
|
|
ffa0ad85ac | ||
| 92f9dfef82 | |||
| 824031f2c0 | |||
| 7db852aaff | |||
|
|
afc0371dd9 | ||
| 7326b9f3be | |||
| cd8ac38d39 | |||
| 469bcc0517 | |||
| e899af303d | |||
| 7a01b3e8f6 | |||
| bff91a4b17 | |||
|
|
66f35b8ae4 | ||
|
|
fe5dbe7fa8 |
65
Dockerfile
65
Dockerfile
@ -7,13 +7,37 @@ RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && n
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Vite bakes VITE_* values at build time, so compose must choose the env file
|
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
|
||||||
# during image build, not only at container runtime.
|
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
|
||||||
ARG ENV_FILE=.env
|
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
|
||||||
COPY ${ENV_FILE} .env
|
ARG VITE_API_BASE
|
||||||
|
ARG VITE_API_URL
|
||||||
# Run build with production mode
|
ARG VITE_APP_NAME
|
||||||
RUN npm run build -- --mode production
|
ARG VITE_APP_HMR_URL
|
||||||
|
ARG VITE_DEV
|
||||||
|
ARG VITE_FRONTEND_SITE_URL
|
||||||
|
ARG VITE_SEARCH_URL
|
||||||
|
ARG VITE_SUPABASE_ANON_KEY
|
||||||
|
ARG VITE_SUPABASE_URL
|
||||||
|
ARG VITE_SUPER_ADMIN_EMAIL
|
||||||
|
ARG VITE_TLSYNC_URL
|
||||||
|
ARG VITE_WHISPERLIVE_URL
|
||||||
|
# Run build with production mode. Keep these as build-step environment values
|
||||||
|
# rather than final-image ENV entries; Vite still embeds the public client config
|
||||||
|
# into the static bundle, but nginx image metadata does not need them.
|
||||||
|
RUN VITE_API_BASE="${VITE_API_BASE}" \
|
||||||
|
VITE_API_URL="${VITE_API_URL}" \
|
||||||
|
VITE_APP_NAME="${VITE_APP_NAME}" \
|
||||||
|
VITE_APP_HMR_URL="${VITE_APP_HMR_URL}" \
|
||||||
|
VITE_DEV="${VITE_DEV}" \
|
||||||
|
VITE_FRONTEND_SITE_URL="${VITE_FRONTEND_SITE_URL}" \
|
||||||
|
VITE_SEARCH_URL="${VITE_SEARCH_URL}" \
|
||||||
|
VITE_SUPABASE_ANON_KEY="${VITE_SUPABASE_ANON_KEY}" \
|
||||||
|
VITE_SUPABASE_URL="${VITE_SUPABASE_URL}" \
|
||||||
|
VITE_SUPER_ADMIN_EMAIL="${VITE_SUPER_ADMIN_EMAIL}" \
|
||||||
|
VITE_TLSYNC_URL="${VITE_TLSYNC_URL}" \
|
||||||
|
VITE_WHISPERLIVE_URL="${VITE_WHISPERLIVE_URL}" \
|
||||||
|
npm run build -- --mode production
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
# Copy built files
|
# Copy built files
|
||||||
@ -31,6 +55,33 @@ RUN echo 'server { \
|
|||||||
expires -1; \
|
expires -1; \
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
||||||
} \
|
} \
|
||||||
|
location = /health { \
|
||||||
|
proxy_pass http://192.168.0.64:18000/health; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
} \
|
||||||
|
location /__ccapi/ { \
|
||||||
|
proxy_pass http://192.168.0.64:18000/; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
} \
|
||||||
|
location /__supabase/ { \
|
||||||
|
proxy_pass http://192.168.0.94:8000/; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
proxy_set_header Upgrade $http_upgrade; \
|
||||||
|
proxy_set_header Connection "upgrade"; \
|
||||||
|
proxy_http_version 1.1; \
|
||||||
|
} \
|
||||||
|
location /api/ { \
|
||||||
|
proxy_pass http://192.168.0.64:18000/api/; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
} \
|
||||||
location / { \
|
location / { \
|
||||||
try_files $uri $uri/ /index.html; \
|
try_files $uri $uri/ /index.html; \
|
||||||
expires 30d; \
|
expires 30d; \
|
||||||
|
|||||||
@ -16,7 +16,28 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
ENV_FILE: .env.dev
|
# app-dev is served by nginx on the app host; browser API calls must stay
|
||||||
|
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
|
||||||
|
# strips that prefix before forwarding, preserving mixed backend routes
|
||||||
|
# such as /api/exam, /me/bootstrap, and /database/timetable.
|
||||||
|
# Supabase is likewise routed same-origin via Dockerfile's /__supabase
|
||||||
|
# proxy -> dev Supabase .94 (no CORS, browser-network-agnostic). The
|
||||||
|
# leading-slash URL is resolved against window.location.origin in
|
||||||
|
# supabaseClient.ts. Provide the matching .94 anon key as a build arg
|
||||||
|
# (VITE_SUPABASE_ANON_KEY) so it pairs with the .94 backend.
|
||||||
|
# .env.dev still points at the LAN API/Supabase for local Vite/dev tooling.
|
||||||
|
VITE_API_BASE: /__ccapi
|
||||||
|
VITE_API_URL: /__ccapi
|
||||||
|
VITE_SUPABASE_URL: /__supabase
|
||||||
|
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
|
||||||
|
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
|
||||||
|
VITE_DEV: ${VITE_DEV:-false}
|
||||||
|
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
|
||||||
|
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
|
||||||
|
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
|
||||||
|
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
|
||||||
|
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
|
||||||
|
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
|
||||||
env_file:
|
env_file:
|
||||||
- .env.dev
|
- .env.dev
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -169,6 +169,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/classes" element={<ClassesPage />} />
|
<Route path="/classes" element={<ClassesPage />} />
|
||||||
<Route path="/my-classes" element={<MyClassesPage />} />
|
<Route path="/my-classes" element={<MyClassesPage />} />
|
||||||
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
||||||
|
<Route path="/timetable/classes/:classId" element={<ClassDetailPage />} />
|
||||||
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
||||||
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
||||||
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
||||||
|
|||||||
@ -102,6 +102,17 @@ const PlatformAdminPage: React.FC = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Destructive reset scope: exam-corpus</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
The <strong>exam-corpus</strong> reset is not limited to public papers. It wipes the entire
|
||||||
|
exam-marker subsystem: public corpus/eb_* data, cc.examboards storage, templates, template
|
||||||
|
layouts, questions, boundaries, response areas, marking batches, student submissions, and
|
||||||
|
mark entries. Use it only when you intend to rebuild all exam-marker data; it does not reset
|
||||||
|
schools, users, or timetable data.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
|
||||||
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, Divider, 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 ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
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 SaveIcon from '@mui/icons-material/Save'
|
||||||
import MouseIcon from '@mui/icons-material/Mouse'
|
import MouseIcon from '@mui/icons-material/Mouse'
|
||||||
import '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
@ -12,7 +14,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 { 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'
|
||||||
@ -29,10 +31,12 @@ const TOOLS = [
|
|||||||
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
|
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
const PAGE_START_X = 260
|
const PAGE_START_X = 0
|
||||||
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
||||||
|
|
||||||
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
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
|
let y = 0
|
||||||
return pages.map((page) => {
|
return pages.map((page) => {
|
||||||
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
|
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
|
||||||
@ -41,6 +45,22 @@ function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) {
|
||||||
|
const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH
|
||||||
|
const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT
|
||||||
|
editor.setCameraOptions({
|
||||||
|
constraints: {
|
||||||
|
bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 },
|
||||||
|
padding: { x: 64, y: 64 },
|
||||||
|
origin: { x: 0.5, y: 0 },
|
||||||
|
initialZoom: 'fit-x-100',
|
||||||
|
baseZoom: 'default',
|
||||||
|
behavior: 'contain',
|
||||||
|
},
|
||||||
|
isLocked: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||||
if (axios.isAxiosError(err)) {
|
if (axios.isAxiosError(err)) {
|
||||||
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
||||||
@ -50,6 +70,18 @@ function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
|||||||
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
|
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function reviewSummary(template: ExamTemplateDetail | null) {
|
||||||
|
if (!template) return { ai: 0, unconfirmed: 0, lowConfidence: 0 }
|
||||||
|
const rows = [...(template.questions ?? []), ...(template.response_areas ?? []), ...(template.boundaries ?? []), ...(template.layout ?? [])]
|
||||||
|
return rows.reduce((acc, row) => {
|
||||||
|
if (row.source === 'ai') acc.ai += 1
|
||||||
|
if (row.source === 'ai' && row.confirmed === false) acc.unconfirmed += 1
|
||||||
|
if (typeof row.confidence === 'number' && row.confidence < 0.7) acc.lowConfidence += 1
|
||||||
|
return acc
|
||||||
|
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
function stripShapePrefix(id: string) {
|
function stripShapePrefix(id: string) {
|
||||||
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||||
}
|
}
|
||||||
@ -85,6 +117,10 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
|||||||
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
||||||
contextType: s.props.contextType,
|
contextType: s.props.contextType,
|
||||||
questionId: s.props.questionId ?? null,
|
questionId: s.props.questionId ?? null,
|
||||||
|
source: s.props.source ?? 'manual',
|
||||||
|
confirmed: s.props.confirmed ?? s.props.source !== 'ai',
|
||||||
|
confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null,
|
||||||
|
derivation: s.props.derivation ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,15 +130,15 @@ function bringDomainShapesToFront(editor: Editor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||||
if (!models.length) return
|
|
||||||
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||||
if (existing.length) editor.deleteShapes(existing)
|
if (existing.length) editor.deleteShapes(existing)
|
||||||
|
if (!models.length) return
|
||||||
editor.createShapes(models.map((m) => ({
|
editor.createShapes(models.map((m) => ({
|
||||||
id: createShapeId(m.id),
|
id: createShapeId(m.id),
|
||||||
type: SHAPE_TYPES[m.kind],
|
type: SHAPE_TYPES[m.kind],
|
||||||
x: m.x,
|
x: m.x,
|
||||||
y: m.y,
|
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 ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') },
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,6 +173,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 ExamTemplateSetupInner: React.FC = () => {
|
||||||
const { templateId } = useParams<{ templateId: string }>()
|
const { templateId } = useParams<{ templateId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -152,6 +202,24 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
const [activeTool, setActiveTool] = useState('select')
|
const [activeTool, setActiveTool] = useState('select')
|
||||||
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
||||||
const [pdfError, setPdfError] = useState<string | null>(null)
|
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
|
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
||||||
|
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
||||||
|
const autoMapPollRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
||||||
|
setTemplate(detail)
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (editor) {
|
||||||
|
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
|
||||||
|
loadShapes(editor, shapes)
|
||||||
|
if (!shapes.length) seedGuide(editor)
|
||||||
|
bringDomainShapesToFront(editor)
|
||||||
|
}
|
||||||
|
setDirty(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const review = useMemo(() => reviewSummary(template), [template])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
@ -176,6 +244,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
|
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
|
||||||
bringDomainShapesToFront(ed)
|
bringDomainShapesToFront(ed)
|
||||||
}
|
}
|
||||||
|
applyDocViewConstraints(ed, partialPages)
|
||||||
}
|
}
|
||||||
setPdfStatus('ready')
|
setPdfStatus('ready')
|
||||||
})
|
})
|
||||||
@ -193,6 +262,8 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
syncPdfPages(editor, pages)
|
syncPdfPages(editor, pages)
|
||||||
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||||
bringDomainShapesToFront(editor)
|
bringDomainShapesToFront(editor)
|
||||||
|
applyDocViewConstraints(editor, pages)
|
||||||
|
editor.resetZoom()
|
||||||
}
|
}
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -206,6 +277,62 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => { void load() }, [load])
|
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 save = useCallback(async () => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (!editor || !templateId || !template) return
|
if (!editor || !templateId || !template) return
|
||||||
@ -227,6 +354,11 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [template, templateId])
|
}, [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) => (
|
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
||||||
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||||||
<Button
|
<Button
|
||||||
@ -248,61 +380,99 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
)), [activeTool])
|
)), [activeTool])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default' }}>
|
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
|
||||||
<Tldraw
|
{/* Top bar — single compact line */}
|
||||||
shapeUtils={examCanvasShapeUtils as any}
|
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
|
||||||
tools={examCanvasTools as any}
|
<Tooltip title="Back to exam marker">
|
||||||
hideUi
|
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
|
||||||
inferDarkMode={theme.palette.mode === 'dark'}
|
</Tooltip>
|
||||||
autoFocus
|
<Divider orientation="vertical" flexItem />
|
||||||
onMount={(editor) => {
|
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
|
||||||
editorRef.current = editor
|
<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'} />
|
||||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
|
||||||
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
<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>
|
||||||
bringDomainShapesToFront(editor)
|
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||||
}}
|
</Paper>
|
||||||
/>
|
|
||||||
|
{/* Body row */}
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Left tool sidebar */}
|
||||||
|
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
||||||
|
<Stack spacing={1}>{toolButtons}</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Canvas area */}
|
||||||
|
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
|
||||||
|
<Tldraw
|
||||||
|
shapeUtils={examCanvasShapeUtils as any}
|
||||||
|
tools={examCanvasTools as any}
|
||||||
|
hideUi
|
||||||
|
inferDarkMode={theme.palette.mode === 'dark'}
|
||||||
|
autoFocus
|
||||||
|
onMount={(editor) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||||
|
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||||
|
applyDocViewConstraints(editor, [])
|
||||||
|
editor.resetZoom()
|
||||||
|
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
||||||
|
// (Previously `else seedGuide` fired on mount while `template` was still null during
|
||||||
|
// the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.)
|
||||||
|
if (template) {
|
||||||
|
const s = shapesFromTemplate(template, pageGeometriesRef.current)
|
||||||
|
loadShapes(editor, s)
|
||||||
|
if (!s.length) seedGuide(editor)
|
||||||
|
}
|
||||||
|
bringDomainShapesToFront(editor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Guide toggle */}
|
||||||
|
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
||||||
|
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
||||||
|
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Guide panel — collapsible */}
|
||||||
|
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
|
||||||
|
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) AI suggestions render dashed/translucent with confidence and cheap review flags; manual shapes stay solid.
|
||||||
|
</Typography>
|
||||||
|
<Alert severity={review.unconfirmed || review.lowConfidence ? 'warning' : 'info'} variant="outlined" sx={{ my: 1 }}>
|
||||||
|
Review layer: {review.ai} AI suggestions, {review.unconfirmed} unconfirmed, {review.lowConfidence} below 70% confidence. Cheap flags include overlap, missing marks, uncertain labels, low confidence, and unconfirmed AI.
|
||||||
|
</Alert>
|
||||||
|
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
|
||||||
|
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
|
||||||
|
const p = canvasShapePalette[kind]
|
||||||
|
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
|
||||||
|
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
||||||
|
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||||
|
Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
{/* Conflict alert */}
|
||||||
|
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Paper elevation={8} sx={{ position: 'absolute', top: 12, left: 12, right: 12, px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1.5, borderRadius: 3, bgcolor: 'background.paper' }}>
|
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
|
||||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} size="small">Back</Button>
|
|
||||||
<Divider orientation="vertical" flexItem />
|
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · coloured tools map to persisted regions; boundary start/end pairs can span pages.</Typography>
|
|
||||||
</Box>
|
|
||||||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
|
||||||
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper elevation={8} sx={{ position: 'absolute', top: 92, left: 12, p: 1.25, borderRadius: 3, bgcolor: 'background.paper' }}>
|
|
||||||
<Stack spacing={1}>{toolButtons}</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 460, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
|
|
||||||
</Typography>
|
|
||||||
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
|
|
||||||
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
|
|
||||||
const p = canvasShapePalette[kind]
|
|
||||||
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
<Divider sx={{ my: 1 }} />
|
|
||||||
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw “Q start” on page N, then “Q end” on a later page; save pairs boundaries by reading order into one question span.</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.75 }}>Open design choices resolved for v1: labels use “Q start/end”; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit.</Typography>
|
|
||||||
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
|
||||||
PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)' }}><CircularProgress /></Box>}
|
|
||||||
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 86, right: 16, maxWidth: 560 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
|
||||||
<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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw'
|
||||||
|
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 { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||||
|
import type { ExamTemplateSource } from '../../../types/exam.types'
|
||||||
|
|
||||||
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||||
|
|
||||||
@ -33,6 +36,11 @@ export type ExamCanvasTLShape = TLBaseBoxShape & {
|
|||||||
contextType?: string
|
contextType?: string
|
||||||
questionId?: string | null
|
questionId?: string | null
|
||||||
domainId?: string
|
domainId?: string
|
||||||
|
source?: 'manual' | 'ai'
|
||||||
|
confirmed?: boolean
|
||||||
|
confidence?: number | null
|
||||||
|
derivation?: string | null
|
||||||
|
reviewFlags?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,13 +70,64 @@ const shapeCss = `
|
|||||||
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
|
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
|
||||||
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
|
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
|
||||||
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
|
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
|
||||||
|
.exam-canvas-shape__flag { background: rgba(251,191,36,.94); color: #78350f; box-shadow: 0 1px 4px rgba(120,53,15,.18); }
|
||||||
|
.exam-canvas-shape__confidence { background: rgba(15,23,42,.82); color: #fff; }
|
||||||
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||||
|
[data-color-mode="dark"] .exam-canvas-shape__flag, .tl-theme__dark .exam-canvas-shape__flag { background: rgba(251,191,36,.88); color: #422006; }
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
||||||
|
function confidenceLabel(confidence: number | null | undefined) {
|
||||||
|
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewFlags(shape: ExamCanvasTLShape): string[] {
|
||||||
|
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function provenanceTitle(shape: ExamCanvasTLShape, base: string) {
|
||||||
|
const bits = [base]
|
||||||
|
if (shape.props.source === 'ai') bits.push(shape.props.confirmed === false ? 'AI suggestion, unconfirmed' : 'AI, confirmed')
|
||||||
|
const confidence = confidenceLabel(shape.props.confidence)
|
||||||
|
if (confidence) bits.push(`confidence ${confidence}`)
|
||||||
|
if (shape.props.derivation) bits.push(`derivation: ${shape.props.derivation}`)
|
||||||
|
const flags = reviewFlags(shape)
|
||||||
|
if (flags.length) bits.push(`review flags: ${flags.join(', ')}`)
|
||||||
|
return bits.join(' • ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
||||||
|
const p = canvasShapePalette.boundary
|
||||||
|
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
||||||
|
const isAi = shape.props.source === 'ai'
|
||||||
|
const confidence = confidenceLabel(shape.props.confidence)
|
||||||
|
const flags = reviewFlags(shape)
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
||||||
|
<style>{shapeCss}</style>
|
||||||
|
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
||||||
|
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isAi ? '4 6' : p.dash} strokeLinecap="round" opacity={isAi ? 0.62 : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
||||||
|
</svg>
|
||||||
|
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
||||||
|
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
||||||
|
{shape.props.label || p.label}
|
||||||
|
</span>
|
||||||
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
|
{flags.slice(0, 2).map((flag, index) => <span key={flag} className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, top: 10 + index * 22, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px' }}>{flag}</span>)}
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function renderShape(shape: ExamCanvasTLShape) {
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
const kind = shape.props.kind
|
const kind = shape.props.kind
|
||||||
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||||
const isBoundary = kind === 'boundary'
|
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 confidence = confidenceLabel(shape.props.confidence)
|
||||||
|
const flags = reviewFlags(shape)
|
||||||
|
const title = provenanceTitle(shape, `${p.label}: ${p.role}`)
|
||||||
return (
|
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: 'all' }}>
|
||||||
<style>{shapeCss}</style>
|
<style>{shapeCss}</style>
|
||||||
@ -80,19 +139,21 @@ function renderShape(shape: ExamCanvasTLShape) {
|
|||||||
'--exam-dark-stroke': p.darkStroke,
|
'--exam-dark-stroke': p.darkStroke,
|
||||||
'--exam-dark-fill': p.darkFill,
|
'--exam-dark-fill': p.darkFill,
|
||||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||||
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
borderStyle: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
background: isBoundary ? 'transparent' : 'var(--exam-fill)', color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
aria-label={`${p.label}: ${p.role}`}
|
aria-label={title}
|
||||||
title={`${p.label}: ${p.role}`}
|
title={title}
|
||||||
>
|
>
|
||||||
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
<span aria-hidden="true">{p.icon}</span>
|
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
||||||
{shape.props.label || p.label}
|
{shape.props.label || p.label}
|
||||||
</span>
|
</span>
|
||||||
{!isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
|
{!confidence && !isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</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>}
|
||||||
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
|
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
|
||||||
</div>
|
</div>
|
||||||
</HTMLContainer>
|
</HTMLContainer>
|
||||||
@ -101,10 +162,10 @@ function renderShape(shape: ExamCanvasTLShape) {
|
|||||||
|
|
||||||
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||||
const p = canvasShapePalette[kind]
|
const p = canvasShapePalette[kind]
|
||||||
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
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) }
|
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 ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||||||
|
|
||||||
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
||||||
@ -121,7 +182,55 @@ class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
|||||||
}
|
}
|
||||||
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||||
}
|
}
|
||||||
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
|
||||||
|
static override type = SHAPE_TYPES.boundary
|
||||||
|
static override props = sharedProps
|
||||||
|
|
||||||
|
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
|
||||||
|
override canEdit() { return false }
|
||||||
|
override canResize() { return false }
|
||||||
|
override canBind() { return false }
|
||||||
|
override hideResizeHandles() { return true }
|
||||||
|
override hideRotateHandle() { return true }
|
||||||
|
override hideSelectionBoundsBg() { return true }
|
||||||
|
|
||||||
|
private pageSpanForY(y: number) {
|
||||||
|
const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE)
|
||||||
|
const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h)
|
||||||
|
const nearest = hit ?? pages.reduce<ExamPdfPageTLShape | null>((best, page) => {
|
||||||
|
if (!best) return page
|
||||||
|
const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h)))
|
||||||
|
const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h)))
|
||||||
|
return pageDy < bestDy ? page : best
|
||||||
|
}, null)
|
||||||
|
return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH }
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape {
|
||||||
|
const span = this.pageSpanForY(shape.y + shape.props.h / 2)
|
||||||
|
return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override getGeometry(shape: ExamCanvasTLShape) {
|
||||||
|
const y = shape.props.h / 2
|
||||||
|
return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override getHandles(shape: ExamCanvasTLShape): TLHandle[] {
|
||||||
|
return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }]
|
||||||
|
}
|
||||||
|
|
||||||
|
override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) }
|
||||||
|
override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) }
|
||||||
|
override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any {
|
||||||
|
return this.normalize({ ...current, x: initial.x })
|
||||||
|
}
|
||||||
|
override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any {
|
||||||
|
return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 })
|
||||||
|
}
|
||||||
|
override component(shape: ExamCanvasTLShape) { return renderShape(shape) }
|
||||||
|
override indicator(shape: ExamCanvasTLShape) { return <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
|
||||||
|
}
|
||||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||||
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
|
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
|
||||||
|
|
||||||
|
|||||||
@ -132,10 +132,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
|
const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
if (clsRes.id) setCls(clsRes);
|
if (clsRes.id) {
|
||||||
|
setCls({
|
||||||
|
...clsRes,
|
||||||
|
class_code: clsRes.class_code || clsRes.code,
|
||||||
|
year_group: clsRes.year_group || clsRes.school_year,
|
||||||
|
teachers: clsRes.teachers || [],
|
||||||
|
students: clsRes.students || [],
|
||||||
|
enrollment_requests: clsRes.enrollment_requests || [],
|
||||||
|
student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
else setError(clsRes.detail || 'Class not found');
|
else setError(clsRes.detail || 'Class not found');
|
||||||
const role = bootstrapData?.active_institute?.membership_role || '';
|
const role = bootstrapData?.active_institute?.membership_role || '';
|
||||||
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
||||||
@ -175,20 +185,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleAddStudent = async (studentId: string) => {
|
const handleAddStudent = async (studentId: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
|
const res = await apiPost(`/database/timetable/classes/${classId}/students`, { student_id: studentId });
|
||||||
if (res.status === 'ok') load();
|
if (res.status === 'ok') load();
|
||||||
else setActionError(res.detail || 'Failed to add student');
|
else setActionError(res.detail || 'Failed to add student');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveStudent = async (studentId: string) => {
|
const handleRemoveStudent = async (studentId: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
await apiDelete(`/classes/${classId}/students/${studentId}`);
|
await apiDelete(`/database/timetable/classes/${classId}/students/${studentId}`);
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
||||||
if (res.status === 'ok') load();
|
if (res.status === 'ok') load();
|
||||||
else setActionError(res.detail || 'Action failed');
|
else setActionError(res.detail || 'Action failed');
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { API_BASE } from '../../config/apiConfig';
|
|||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
import type {
|
import type {
|
||||||
|
AutoMapJobStatus,
|
||||||
|
AutoMapResponse,
|
||||||
BatchQueueResponse,
|
BatchQueueResponse,
|
||||||
BatchResultsResponse,
|
BatchResultsResponse,
|
||||||
CreateBatchPayload,
|
CreateBatchPayload,
|
||||||
@ -20,6 +22,7 @@ import type {
|
|||||||
ExamResponseArea,
|
ExamResponseArea,
|
||||||
ExamTemplate,
|
ExamTemplate,
|
||||||
ExamTemplateDetail,
|
ExamTemplateDetail,
|
||||||
|
ExamTemplateLayout,
|
||||||
MarkingBatch,
|
MarkingBatch,
|
||||||
MarkUpsertPayload,
|
MarkUpsertPayload,
|
||||||
Neo4jSyncResult,
|
Neo4jSyncResult,
|
||||||
@ -64,6 +67,10 @@ function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
|
|||||||
spec_ref: q.spec_ref,
|
spec_ref: q.spec_ref,
|
||||||
bounds: q.bounds ?? null,
|
bounds: q.bounds ?? null,
|
||||||
page: q.page ?? null,
|
page: q.page ?? null,
|
||||||
|
source: q.source ?? 'manual',
|
||||||
|
confirmed: q.confirmed ?? true,
|
||||||
|
confidence: q.confidence ?? null,
|
||||||
|
derivation: q.derivation ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +86,8 @@ function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, d
|
|||||||
source: r.source,
|
source: r.source,
|
||||||
confirmed: r.confirmed,
|
confirmed: r.confirmed,
|
||||||
confidence: r.confidence,
|
confidence: r.confidence,
|
||||||
|
mark_subtype: r.mark_subtype ?? null,
|
||||||
|
derivation: r.derivation ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,6 +101,26 @@ function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate
|
|||||||
bounds: b.bounds,
|
bounds: b.bounds,
|
||||||
source: b.source,
|
source: b.source,
|
||||||
confirmed: b.confirmed,
|
confirmed: b.confirmed,
|
||||||
|
confidence: b.confidence ?? null,
|
||||||
|
derivation: b.derivation ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutPayload(layout: ExamTemplateLayout, duplicate = false) {
|
||||||
|
return {
|
||||||
|
id: duplicate ? newUuid() : layout.id,
|
||||||
|
page_index: layout.page_index,
|
||||||
|
role: layout.role ?? null,
|
||||||
|
margin_left: layout.margin_left ?? null,
|
||||||
|
margin_right: layout.margin_right ?? null,
|
||||||
|
margin_top: layout.margin_top ?? null,
|
||||||
|
margin_bottom: layout.margin_bottom ?? null,
|
||||||
|
margins_enabled: layout.margins_enabled ?? true,
|
||||||
|
source: layout.source ?? 'manual',
|
||||||
|
confirmed: layout.confirmed ?? true,
|
||||||
|
confidence: layout.confidence ?? null,
|
||||||
|
derivation: layout.derivation ?? null,
|
||||||
|
meta: layout.meta ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,6 +142,7 @@ async function replaceTemplate(
|
|||||||
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
||||||
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
||||||
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
||||||
|
layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)),
|
||||||
},
|
},
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
@ -135,6 +165,18 @@ export const examRepository = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async autoMapTemplate(templateId: string): Promise<AutoMapResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<AutoMapResponse>(`${EXAM_BASE}/templates/${templateId}/auto-map`, {}, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAutoMapStatus(templateId: string, jobId: string): Promise<AutoMapJobStatus> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<AutoMapJobStatus>(`${EXAM_BASE}/templates/${templateId}/auto-map/${jobId}/status`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
|
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { logger } from './debugConfig';
|
import { logger } from './debugConfig';
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
// Same-origin proxy support: a leading-slash value (e.g. "/__supabase") is
|
||||||
|
// resolved against the current browser origin so supabase-js receives an
|
||||||
|
// absolute URL while every request stays same-origin (no CORS) and routes
|
||||||
|
// through the app host's /__supabase nginx proxy to the backend Supabase.
|
||||||
|
const supabaseUrl = rawSupabaseUrl?.startsWith('/')
|
||||||
|
? `${window.location.origin}${rawSupabaseUrl}`
|
||||||
|
: rawSupabaseUrl;
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
throw new Error('Missing Supabase configuration');
|
throw new Error('Missing Supabase configuration');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,8 @@ export interface UpdateTemplateMetaPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||||
|
export type ExamTemplateSource = 'manual' | 'ai';
|
||||||
|
|
||||||
export interface ExamQuestion {
|
export interface ExamQuestion {
|
||||||
id: string;
|
id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
@ -89,6 +91,10 @@ export interface ExamQuestion {
|
|||||||
spec_ref: string | null;
|
spec_ref: string | null;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
page?: number | null;
|
page?: number | null;
|
||||||
|
source: ExamTemplateSource;
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
derivation: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExamResponseAreaKind =
|
export type ExamResponseAreaKind =
|
||||||
@ -99,6 +105,8 @@ export type ExamResponseAreaKind =
|
|||||||
| 'reference'
|
| 'reference'
|
||||||
| 'furniture';
|
| 'furniture';
|
||||||
|
|
||||||
|
export type ExamMarkSubtype = 'part_marks' | 'question_total' | 'grader_box';
|
||||||
|
|
||||||
export interface ExamResponseArea {
|
export interface ExamResponseArea {
|
||||||
id: string;
|
id: string;
|
||||||
question_id: string;
|
question_id: string;
|
||||||
@ -108,9 +116,11 @@ export interface ExamResponseArea {
|
|||||||
kind: ExamResponseAreaKind;
|
kind: ExamResponseAreaKind;
|
||||||
response_form: string | null;
|
response_form: string | null;
|
||||||
context_type?: string | null;
|
context_type?: string | null;
|
||||||
source: 'manual' | 'ai';
|
source: ExamTemplateSource;
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
confidence: number | null;
|
confidence: number | null;
|
||||||
|
mark_subtype?: ExamMarkSubtype | null;
|
||||||
|
derivation?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamBoundary {
|
export interface ExamBoundary {
|
||||||
@ -121,14 +131,36 @@ export interface ExamBoundary {
|
|||||||
page_index: number;
|
page_index: number;
|
||||||
y: number;
|
y: number;
|
||||||
bounds: Record<string, number> | null;
|
bounds: Record<string, number> | null;
|
||||||
source: 'manual' | 'ai';
|
source: ExamTemplateSource;
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
derivation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamTemplateLayout {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
page_index: number;
|
||||||
|
role: string | null;
|
||||||
|
margin_left: number | null;
|
||||||
|
margin_right: number | null;
|
||||||
|
margin_top: number | null;
|
||||||
|
margin_bottom: number | null;
|
||||||
|
margins_enabled: boolean;
|
||||||
|
source: ExamTemplateSource;
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
derivation: string | null;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamTemplateDetail extends ExamTemplate {
|
export interface ExamTemplateDetail extends ExamTemplate {
|
||||||
questions: ExamQuestion[];
|
questions: ExamQuestion[];
|
||||||
response_areas: ExamResponseArea[];
|
response_areas: ExamResponseArea[];
|
||||||
boundaries: ExamBoundary[];
|
boundaries: ExamBoundary[];
|
||||||
|
layout: ExamTemplateLayout[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -152,6 +184,10 @@ export interface TemplateReplacePayload {
|
|||||||
spec_ref?: string | null;
|
spec_ref?: string | null;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
page?: number | null;
|
page?: number | null;
|
||||||
|
source?: ExamTemplateSource;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
}>;
|
}>;
|
||||||
response_areas: Array<{
|
response_areas: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -164,6 +200,8 @@ export interface TemplateReplacePayload {
|
|||||||
source?: 'manual' | 'ai';
|
source?: 'manual' | 'ai';
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
confidence?: number | null;
|
confidence?: number | null;
|
||||||
|
mark_subtype?: ExamMarkSubtype | null;
|
||||||
|
derivation?: string | null;
|
||||||
}>;
|
}>;
|
||||||
boundaries: Array<{
|
boundaries: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -172,8 +210,25 @@ export interface TemplateReplacePayload {
|
|||||||
page_index: number;
|
page_index: number;
|
||||||
y: number;
|
y: number;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
source?: 'manual' | 'ai';
|
source?: ExamTemplateSource;
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
|
}>;
|
||||||
|
layout?: Array<{
|
||||||
|
id?: string;
|
||||||
|
page_index: number;
|
||||||
|
role?: string | null;
|
||||||
|
margin_left?: number | null;
|
||||||
|
margin_right?: number | null;
|
||||||
|
margin_top?: number | null;
|
||||||
|
margin_bottom?: number | null;
|
||||||
|
margins_enabled?: boolean;
|
||||||
|
source?: ExamTemplateSource;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,6 +257,23 @@ export interface Neo4jSyncResult {
|
|||||||
projection?: Record<string, unknown>;
|
projection?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutoMapAcceptedResponse {
|
||||||
|
status: 'accepted';
|
||||||
|
job_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoMapJobStatus {
|
||||||
|
job_id: string;
|
||||||
|
status: 'queued' | 'running' | 'completed' | 'failed' | string;
|
||||||
|
template_id: string;
|
||||||
|
updated_at?: number;
|
||||||
|
counts?: Record<string, number>;
|
||||||
|
error?: string;
|
||||||
|
template?: ExamTemplateDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoMapResponse = ExamTemplateDetail | AutoMapAcceptedResponse;
|
||||||
|
|
||||||
export interface MarkingBatch {
|
export interface MarkingBatch {
|
||||||
id: string;
|
id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './m
|
|||||||
|
|
||||||
const template: ExamTemplateDetail = {
|
const template: ExamTemplateDetail = {
|
||||||
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
||||||
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
|
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], layout: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('exam setup canvas serialization', () => {
|
describe('exam setup canvas serialization', () => {
|
||||||
@ -43,6 +43,7 @@ describe('exam setup canvas serialization', () => {
|
|||||||
], pages)
|
], pages)
|
||||||
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
||||||
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
||||||
|
expect(payload.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true)
|
||||||
expect(payload.response_areas[0].page).toBe(2)
|
expect(payload.response_areas[0].page).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -50,16 +51,45 @@ describe('exam setup canvas serialization', () => {
|
|||||||
const shapes = shapesFromTemplate({
|
const shapes = shapesFromTemplate({
|
||||||
...template,
|
...template,
|
||||||
questions: [
|
questions: [
|
||||||
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
|
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
|
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
],
|
],
|
||||||
response_areas: [
|
response_areas: [
|
||||||
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
|
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
|
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
],
|
],
|
||||||
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true, confidence: null, derivation: null }],
|
||||||
})
|
})
|
||||||
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||||
|
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
|
||||||
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('carries AI provenance into canvas models, flags cheap review issues, and preserves it on save', () => {
|
||||||
|
const detail: ExamTemplateDetail = {
|
||||||
|
...template,
|
||||||
|
questions: [
|
||||||
|
{ id: '11111111-1111-4111-8111-111111111111', template_id: 'tpl-1', parent_id: null, label: 'Q?', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||||
|
{ id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q?', order: 0, max_marks: 0, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 100, y: 120, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||||
|
{ id: '66666666-6666-4666-8666-666666666666', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q1(b)', order: 1, max_marks: 1, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 150, y: 150, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.8, derivation: 'g6' },
|
||||||
|
],
|
||||||
|
response_areas: [
|
||||||
|
{ id: '33333333-3333-4333-8333-333333333333', question_id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', page: 1, bounds: { x: 120, y: 140, w: 120, h: 40 }, kind: 'response', response_form: 'lines', source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' },
|
||||||
|
],
|
||||||
|
boundaries: [
|
||||||
|
{ id: '44444444-4444-4444-8444-444444444444', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||||
|
{ id: '55555555-5555-4555-8555-555555555555', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? end', page_index: 0, y: 500, bounds: { x: 0, y: 500, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const shapes = shapesFromTemplate(detail)
|
||||||
|
const part = shapes.find((shape) => shape.kind === 'part')
|
||||||
|
expect(part).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||||
|
expect(part?.reviewFlags).toEqual(expect.arrayContaining(['unconfirmed AI', 'low confidence', 'uncertain question label', 'missing marks', 'overlapping shapes']))
|
||||||
|
|
||||||
|
const payload = serializeCanvasShapes(template, shapes)
|
||||||
|
expect(payload.questions.find((q) => q.id === '22222222-2222-4222-8222-222222222222')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||||
|
expect(payload.response_areas.find((r) => r.id === '33333333-3333-4333-8333-333333333333')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' })
|
||||||
|
expect(payload.boundaries.find((b) => b.id === '44444444-4444-4444-8444-444444444444')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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_HEIGHT = 1100
|
||||||
export const PAGE_WIDTH = 780
|
export const PAGE_WIDTH = 780
|
||||||
@ -10,7 +10,7 @@ export interface CanvasPageGeometry { pageNumber: number; x: number; y: number;
|
|||||||
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||||
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||||
|
|
||||||
export interface CanvasBounds { x: number; y: number; w: number; h: number }
|
export interface CanvasBounds extends Record<string, number> { x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
export interface ExamCanvasShapeModel {
|
export interface ExamCanvasShapeModel {
|
||||||
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
||||||
@ -26,6 +26,11 @@ export interface ExamCanvasShapeModel {
|
|||||||
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
||||||
contextType?: string
|
contextType?: string
|
||||||
questionId?: string | null
|
questionId?: string | null
|
||||||
|
source?: ExamTemplateSource
|
||||||
|
confirmed?: boolean
|
||||||
|
confidence?: number | null
|
||||||
|
derivation?: string | null
|
||||||
|
reviewFlags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
|
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
|
||||||
@ -65,10 +70,35 @@ export function newDomainId(): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
|
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds & Record<string, number> {
|
||||||
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageGeometry(pageNumber: number, pages?: CanvasPageGeometry[]): CanvasPageGeometry {
|
||||||
|
return pages?.find((page) => page.pageNumber === pageNumber) ?? { pageNumber, x: 0, y: pageTop(pageNumber, pages), w: PAGE_WIDTH, h: PAGE_HEIGHT }
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundaryBounds(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): CanvasBounds & Record<string, number> {
|
||||||
|
const page = pageGeometry(pageForShape(shape, pages), pages)
|
||||||
|
return { x: page.x, y: shape.y, w: page.w, h: 8 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistedSource(shape: ExamCanvasShapeModel): ExamTemplateSource {
|
||||||
|
return shape.source ?? 'manual'
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistedConfirmed(shape: ExamCanvasShapeModel): boolean {
|
||||||
|
return shape.confirmed ?? persistedSource(shape) !== 'ai'
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistedConfidence(shape: ExamCanvasShapeModel): number | null {
|
||||||
|
return typeof shape.confidence === 'number' ? shape.confidence : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistedDerivation(shape: ExamCanvasShapeModel): string | null {
|
||||||
|
return shape.derivation ?? null
|
||||||
|
}
|
||||||
|
|
||||||
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
||||||
const ox2 = outer.x + outer.w
|
const ox2 = outer.x + outer.w
|
||||||
const oy2 = outer.y + outer.h
|
const oy2 = outer.y + outer.h
|
||||||
@ -102,10 +132,10 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const qNum = bands.length + 1
|
const qNum = bands.length + 1
|
||||||
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
|
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}`
|
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: {} })
|
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) })
|
||||||
bands.push({ questionId, top, bottom })
|
bands.push({ questionId, top, bottom })
|
||||||
for (const b of [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: bounds(b), source: 'manual', confirmed: true })
|
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) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +144,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
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 qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
||||||
partQuestionIds.set(part.id, qid)
|
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) })
|
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) })
|
||||||
})
|
})
|
||||||
|
|
||||||
const response_areas: TemplateReplacePayload['response_areas'] = []
|
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||||
@ -124,27 +154,55 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||||
if (!questionId) continue
|
if (!questionId) continue
|
||||||
const kind = region.kind as ExamCanvasRegionKind
|
const kind = region.kind as ExamCanvasRegionKind
|
||||||
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: 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: 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 }
|
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 ?? [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||||
const shapes: ExamCanvasShapeModel[] = []
|
const shapes: ExamCanvasShapeModel[] = []
|
||||||
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||||
for (const b of detail.boundaries ?? []) {
|
for (const b of detail.boundaries ?? []) {
|
||||||
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
|
const page = pageGeometry((b.page_index ?? 0) + 1, pages)
|
||||||
shapes.push({ id: b.id, kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
|
// 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 })
|
||||||
}
|
}
|
||||||
for (const q of detail.questions ?? []) {
|
for (const q of detail.questions ?? []) {
|
||||||
if (q.is_container || !q.bounds) continue
|
if (q.is_container || !q.bounds) continue
|
||||||
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
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 ?? []) {
|
for (const r of detail.response_areas ?? []) {
|
||||||
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
||||||
const q = questions.get(r.question_id)
|
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 ?? 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
|
return addCheapReviewFlags(shapes, pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(a: ExamCanvasShapeModel, b: ExamCanvasShapeModel): boolean {
|
||||||
|
const ax2 = a.x + a.w
|
||||||
|
const ay2 = a.y + a.h
|
||||||
|
const bx2 = b.x + b.w
|
||||||
|
const by2 = b.y + b.h
|
||||||
|
return a.x < bx2 && ax2 > b.x && a.y < by2 && ay2 > b.y
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksUncertainLabel(label: string | undefined): boolean {
|
||||||
|
return !label || /\b(unknown|uncertain|maybe|todo|tbd)\b|\?/.test(label.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCheapReviewFlags(shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||||
|
const markAreasByQuestion = new Set(shapes.filter((shape) => shape.kind === 'mark_area' && shape.questionId).map((shape) => shape.questionId as string))
|
||||||
|
return shapes.map((shape, index) => {
|
||||||
|
const flags: string[] = []
|
||||||
|
if (shape.source === 'ai' && shape.confirmed === false) flags.push('unconfirmed AI')
|
||||||
|
if (typeof shape.confidence === 'number' && shape.confidence < 0.7) flags.push('low confidence')
|
||||||
|
if ((shape.kind === 'part' || shape.kind === 'question_number') && looksUncertainLabel(shape.label)) flags.push('uncertain question label')
|
||||||
|
if (shape.kind === 'part' && (!shape.maxMarks || shape.maxMarks <= 0) && !markAreasByQuestion.has(shape.questionId ?? shape.id)) flags.push('missing marks')
|
||||||
|
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)))))
|
||||||
|
if (samePageOverlap) flags.push('overlapping shapes')
|
||||||
|
return flags.length ? { ...shape, reviewFlags: flags } : shape
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user