diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index bb5ac55..6d35589 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -70,6 +70,18 @@ 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 }) +} + function stripShapePrefix(id: string) { return id.startsWith('shape:') ? id.slice('shape:'.length) : id } @@ -105,9 +117,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 +138,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('|') }, }))) } @@ -205,6 +217,8 @@ const ExamTemplateSetupInner: React.FC = () => { setDirty(false) }, []) + const review = useMemo(() => reviewSummary(template), [template]) + const load = useCallback(async () => { if (!templateId) return setLoading(true); setError(null); setConflict(null) @@ -373,6 +387,7 @@ const ExamTemplateSetupInner: React.FC = () => { {template?.title ?? 'Template setup'} + {(autoMapBusy || autoMapStatus) && } @@ -420,8 +435,11 @@ const ExamTemplateSetupInner: React.FC = () => { Setup guide - 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. + + 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. + {(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => { const p = canvasShapePalette[kind] diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index 7d76056..b388475 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -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,50 @@ 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 +} + +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 ( - + - - {shape.props.source === 'ai' && shape.props.confirmed === false ? 'AI · ' : ''}{shape.props.label || p.label} + + {shape.props.label || p.label} + {confidence && {confidence}} + {flags.slice(0, 2).map((flag, index) => {flag})} ) } @@ -95,6 +124,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 ( @@ -106,19 +139,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: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10, + 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', 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} > - - {isAiSuggestion ? 'AI · ' : ''}{shape.props.label || p.label} + + {shape.props.label || p.label} - {!isBoundary && shape.props.questionId && Attached} + {confidence && {confidence}} + {!confidence && !isBoundary && shape.props.questionId && Attached} + {flags.length > 0 && {flags.slice(0, 2).join(' · ')}} {isBoundary && pair across pages} @@ -127,10 +162,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) => class PdfPageUtil extends BaseBoxShapeUtil { diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts index 90c3b9a..9e83a87 100644 --- a/src/utils/exam-canvas/model.test.ts +++ b/src/utils/exam-canvas/model.test.ts @@ -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' }) + }) + }) diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts index acf6d15..c18a27b 100644 --- a/src/utils/exam-canvas/model.ts +++ b/src/utils/exam-canvas/model.ts @@ -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, 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 + }) }