Compare commits

...
Author SHA1 Message Date
CC WorkerandClaude Opus 4.8 f72ea7189c FX-4: confirm/dismiss UI for AI ghost suggestions
The S5-7 review layer could surface unconfirmed AI shapes but had no way to
resolve them (confirmed was never set true), so the only "dismiss" was deletion
and the unconfirmed count never dropped. Add the resolution loop:

- A floating review-actions panel (shown while unconfirmed > 0): Accept selected,
  Reject (delete) selected, and Accept all AI on the page. Accept flips
  props.confirmed = true.
- Human edit = implicit confirm: nudging/resizing an AI ghost marks it confirmed
  (R3.3 — a teacher who adjusts a suggestion has reviewed it). Geometry-only
  trigger, so the confirmed-only update can't recurse.
- Confidence-driven opacity: unconfirmed ghosts fade by confidence instead of a
  constant 0.72; confirmed AI renders solid with its kind icon (like manual).
- Live review counts read straight off the canvas so the unconfirmed chip/panel
  update immediately on accept/reject (the old count reflected only saved state).

Persists through the existing PUT (confirmed already round-trips). No schema or
API change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:08:38 +00:00
CC WorkerandClaude Opus 4.8 7bd66fbaf0 fix(exam): stop placeholder guide shapes flashing before template loads
app-ci-deploy / test-build-deploy (push) Has been cancelled
seedGuide() ran in onMount via the 'else' branch while template was still null during the async
fetch, creating 5 example shapes (Q1 start/end, part, response, context) that flashed on screen
until the real template + PDF loaded. Only seed the guide for a genuinely-empty template after load.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-08 19:19:15 +00:00
kcar 9c3a5f97cc docs(admin): clarify exam-corpus reset warning
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-08 00:58:03 +01:00
CC WorkerandClaude Opus 4.8 2ccfb9ccd6 fix(dev): route Supabase same-origin via /__supabase proxy
app-ci-deploy / test-build-deploy (push) Has been cancelled
app-dev built with --mode production was baking the PROD Supabase URL (.env)
into the bundle, so browser auth went cross-origin to supa.classroomcopilot.ai
and was CORS-blocked (and hit the wrong user store). Mirror the /__ccapi fix:
- Dockerfile: nginx /__supabase/ -> dev Supabase .94:8000 (+WS upgrade for realtime)
- supabaseClient.ts: resolve a leading-slash VITE_SUPABASE_URL against
  window.location.origin so supabase-js gets an absolute same-origin URL
- docker-compose.dev.yml: bake VITE_SUPABASE_URL=/__supabase (like VITE_API_BASE)
Browser now talks only to the app host (Tailscale or LAN), no CORS, dev .94 store.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 21:20:57 +00:00
CC Worker ef13a124dd Merge S5-7 G6 review wiring
app-ci-deploy / test-build-deploy (push) Has been cancelled
# Conflicts:
#	src/pages/exam/setup/ExamTemplateSetupPage.tsx
#	src/pages/exam/setup/examCanvasShapes.tsx
#	src/utils/exam-canvas/model.ts
2026-06-07 20:06:45 +00:00
kcarandClaude Opus 4.8 92f9dfef82 S5-7: basic G6 review wiring (dashed/translucent AI shapes, confidence, flags)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 20:56:57 +01:00
8 changed files with 281 additions and 29 deletions
+9
View File
@@ -67,6 +67,15 @@ RUN echo 'server { \
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; \
+7 -2
View File
@@ -20,16 +20,21 @@ services:
# 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.
# .env.dev still points at the LAN API for local Vite/dev tooling.
# 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_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:-}
+11
View File
@@ -102,6 +102,17 @@ const PlatformAdminPage: React.FC = () => {
</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>
{loading ? (
+114 -8
View File
@@ -7,6 +7,9 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
import SaveIcon from '@mui/icons-material/Save'
import MouseIcon from '@mui/icons-material/Mouse'
import CheckIcon from '@mui/icons-material/Check'
import CloseIcon from '@mui/icons-material/Close'
import DoneAllIcon from '@mui/icons-material/DoneAll'
import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
import axios from 'axios'
@@ -70,6 +73,33 @@ function apiMessage(err: unknown): { message: string; conflict: boolean } {
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 })
}
// Live review counts read straight off the canvas shapes, so accept/reject update them immediately
// (the template-derived reviewSummary only reflects the last *saved* state).
function reviewFromShapes(editor: Editor | null) {
const acc = { ai: 0, unconfirmed: 0, lowConfidence: 0 }
if (!editor) return acc
for (const shape of editor.getCurrentPageShapes()) {
if (!shapeTypeToKind(shape.type)) continue
const p = (shape as ExamCanvasTLShape).props
if (p.source === 'ai') acc.ai += 1
if (p.source === 'ai' && p.confirmed === false) acc.unconfirmed += 1
if (typeof p.confidence === 'number' && p.confidence < 0.7) acc.lowConfidence += 1
}
return acc
}
function stripShapePrefix(id: string) {
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
}
@@ -105,9 +135,9 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
contextType: s.props.contextType,
questionId: s.props.questionId ?? null,
source: s.props.source,
confirmed: s.props.confirmed,
confidence: s.props.confidence ?? null,
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,
}
}
@@ -126,7 +156,7 @@ function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
type: SHAPE_TYPES[m.kind],
x: m.x,
y: m.y,
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id, source: m.source, confirmed: m.confirmed, confidence: m.confidence, derivation: m.derivation },
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('|') },
})))
}
@@ -193,18 +223,51 @@ const ExamTemplateSetupInner: React.FC = () => {
const [guideOpen, setGuideOpen] = useState(false)
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
const [autoMapBusy, setAutoMapBusy] = useState(false)
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
const autoMapPollRef = useRef<number | null>(null)
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
setTemplate(detail)
const editor = editorRef.current
if (editor) {
loadShapes(editor, shapesFromTemplate(detail, pageGeometriesRef.current))
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
loadShapes(editor, shapes)
if (!shapes.length) seedGuide(editor)
bringDomainShapesToFront(editor)
}
setDirty(false)
}, [])
const acceptShapes = useCallback((shapes: TLShape[]) => {
const editor = editorRef.current
if (!editor) return
const updates = shapes
.filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false)
.map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } }))
if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) }
}, [refreshReview])
const selectedDomainShapes = useCallback((): TLShape[] => {
const editor = editorRef.current
if (!editor) return []
return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type))
}, [])
const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes])
const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes])
const rejectSelected = useCallback(() => {
const editor = editorRef.current
if (!editor) return
const ids = selectedDomainShapes().map((s) => s.id)
if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) }
}, [refreshReview, selectedDomainShapes])
// Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the
// saved-template summary only for the brief moment before shapes have mounted).
const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template)
const load = useCallback(async () => {
if (!templateId) return
setLoading(true); setError(null); setConflict(null)
@@ -373,6 +436,7 @@ const ExamTemplateSetupInner: React.FC = () => {
</Tooltip>
<Divider orientation="vertical" flexItem />
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
<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>
@@ -399,15 +463,54 @@ const ExamTemplateSetupInner: React.FC = () => {
onMount={(editor) => {
editorRef.current = editor
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
editor.store.listen(() => setDirty(true), { scope: 'document' })
editor.store.listen(() => { setDirty(true); refreshReview() }, { scope: 'document' })
// Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who
// adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only
// update below can't recurse.
editor.store.listen((entry) => {
try {
const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = []
for (const rec of Object.values(entry.changes.updated)) {
const [from, to] = rec as [any, any]
if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue
if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue
const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h
if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } })
}
if (updates.length) editor.updateShapes(updates as any)
} catch { /* non-fatal: implicit-confirm is best-effort */ }
}, { scope: 'document', source: 'user' })
applyDocViewConstraints(editor, [])
editor.resetZoom()
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
// 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>
{/* AI review actions — appears while there are unconfirmed suggestions to resolve */}
{review.unconfirmed > 0 && (
<Paper elevation={6} sx={{ position: 'absolute', left: 16, bottom: 16, zIndex: 1002, px: 1, py: 0.75, borderRadius: 2, bgcolor: 'background.paper', display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip size="small" color="warning" label={`${review.unconfirmed} AI unconfirmed`} sx={{ fontWeight: 700 }} />
<Tooltip title="Accept the selected AI suggestions (nudging one also confirms it)">
<span><Button size="small" color="success" startIcon={<CheckIcon fontSize="small" />} onClick={acceptSelected}>Accept</Button></span>
</Tooltip>
<Tooltip title="Reject (delete) the selected shapes">
<span><Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} onClick={rejectSelected}>Reject</Button></span>
</Tooltip>
<Tooltip title="Accept every AI suggestion on the page">
<span><Button size="small" variant="contained" startIcon={<DoneAllIcon fontSize="small" />} onClick={acceptAllAi}>Accept all</Button></span>
</Tooltip>
</Paper>
)}
{/* Guide toggle */}
<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' } }}>
@@ -420,8 +523,11 @@ const ExamTemplateSetupInner: React.FC = () => {
<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) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
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]
+55 -13
View File
@@ -36,10 +36,11 @@ export type ExamCanvasTLShape = TLBaseBoxShape & {
contextType?: string
questionId?: string | null
domainId?: string
source?: ExamTemplateSource
source?: 'manual' | 'ai'
confirmed?: boolean
confidence?: number | null
derivation?: string | null
reviewFlags?: string
}
}
@@ -69,22 +70,57 @@ const shapeCss = `
.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); }
.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__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
}
// Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid.
function ghostOpacity(confidence: number | null | undefined) {
if (typeof confidence !== 'number') return 0.72
return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence))
}
function reviewFlags(shape: ExamCanvasTLShape): string[] {
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 isGhost = isAi && shape.props.confirmed === false
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={p.dash} opacity={shape.props.source === 'ai' && shape.props.confirmed === false ? 0.55 : 1} strokeLinecap="round" style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
</svg>
<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">{p.icon}</span>
{shape.props.source === 'ai' && shape.props.confirmed === false ? 'AI · ' : ''}{shape.props.label || p.label}
<span aria-hidden="true">{isGhost ? '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>
)
}
@@ -95,6 +131,10 @@ function renderShape(shape: ExamCanvasTLShape) {
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 (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
<style>{shapeCss}</style>
@@ -106,19 +146,21 @@ function renderShape(shape: ExamCanvasTLShape) {
'--exam-dark-stroke': p.darkStroke,
'--exam-dark-fill': p.darkFill,
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
borderStyle: isAiSuggestion || p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? 0.58 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
} as React.CSSProperties}
aria-label={`${p.label}: ${p.role}`}
title={`${p.label}: ${p.role}`}
aria-label={title}
title={title}
>
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span aria-hidden="true">{p.icon}</span>
{isAiSuggestion ? 'AI · ' : ''}{shape.props.label || p.label}
<span aria-hidden="true">{isAiSuggestion ? 'AI' : p.icon}</span>
{shape.props.label || p.label}
</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>}
</div>
</HTMLContainer>
@@ -127,10 +169,10 @@ function renderShape(shape: ExamCanvasTLShape) {
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
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), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: 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)} />
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
+9 -1
View File
@@ -1,9 +1,17 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
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;
// 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) {
throw new Error('Missing Supabase configuration');
}
+28
View File
@@ -64,4 +64,32 @@ describe('exam setup canvas serialization', () => {
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 })
})
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' })
})
})
+48 -5
View File
@@ -30,6 +30,7 @@ export interface ExamCanvasShapeModel {
confirmed?: boolean
confidence?: number | null
derivation?: string | null
reviewFlags?: string[]
}
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
@@ -82,6 +83,22 @@ function boundaryBounds(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: Ca
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 {
const ox2 = outer.x + outer.w
const oy2 = outer.y + outer.h
@@ -115,10 +132,10 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
const qNum = bands.length + 1
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {}, source: top.source ?? bottom.source ?? 'manual', confirmed: top.confirmed ?? bottom.confirmed ?? true, confidence: top.confidence ?? bottom.confidence ?? null, derivation: top.derivation ?? bottom.derivation ?? null })
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 })
for (const b of [top, bottom]) {
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: b.source ?? 'manual', confirmed: b.confirmed ?? true, confidence: b.confidence ?? null, derivation: b.derivation ?? null })
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: persistedSource(b), confirmed: persistedConfirmed(b), confidence: persistedConfidence(b), derivation: persistedDerivation(b) })
}
}
@@ -127,7 +144,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
partQuestionIds.set(part.id, qid)
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: part.source ?? 'manual', confirmed: part.confirmed ?? true, confidence: part.confidence ?? null, derivation: part.derivation ?? null })
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
})
const response_areas: TemplateReplacePayload['response_areas'] = []
@@ -137,7 +154,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
if (!questionId) continue
const kind = region.kind as ExamCanvasRegionKind
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: region.source ?? 'manual', confirmed: region.confirmed ?? true, confidence: region.confidence ?? null, mark_subtype: null, derivation: region.derivation ?? null })
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) })
}
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] }
@@ -161,5 +178,31 @@ export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPag
const q = questions.get(r.question_id)
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `${q.label}` : r.kind, responseForm: (r.response_form as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id, source: r.source, confirmed: r.confirmed, confidence: r.confidence, derivation: r.derivation })
}
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
})
}