diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 62dc12b..7186213 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -141,6 +141,7 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null { depth: s.props.depth, description: s.props.description, linkLabel: s.props.linkLabel, + meta: (() => { try { return s.props.metaJson ? JSON.parse(s.props.metaJson) : undefined } catch { return undefined } })(), source: s.props.source ?? 'manual', confirmed: s.props.confirmed ?? s.props.source !== 'ai', confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null, @@ -162,7 +163,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, parentId: m.parentId ?? undefined, isContainer: m.isContainer ?? undefined, depth: m.depth ?? undefined, description: m.description ?? undefined, linkLabel: m.linkLabel ?? undefined, 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('|') }, + 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, parentId: m.parentId ?? undefined, isContainer: m.isContainer ?? undefined, depth: m.depth ?? undefined, description: m.description ?? undefined, linkLabel: m.linkLabel ?? undefined, metaJson: m.meta ? JSON.stringify(m.meta) : undefined, 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('|') }, }))) } diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index cee137d..f40bcb9 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -40,6 +40,7 @@ export type ExamCanvasTLShape = TLBaseBoxShape & { depth?: number description?: string linkLabel?: string + metaJson?: string domainId?: string source?: 'manual' | 'ai' confirmed?: boolean @@ -157,6 +158,39 @@ function marksPill(maxMarks: number | undefined) { ) } +function parseMeta(shape: ExamCanvasTLShape): Record | null { + try { return shape.props.metaJson ? JSON.parse(shape.props.metaJson) : null } catch { return null } +} + +// Draw the recognised answer sub-structure INSIDE a response box, using the box-relative [0,1] coords the +// contract now carries: numbered sub-answer bands ("suggest two" → 1, 2), per-option MC selection boxes, and +// the final-answer line's expected quantity/unit. +function renderResponseSubstructure(shape: ExamCanvasTLShape): React.ReactNode { + const m = parseMeta(shape) + if (!m) return null + const pct = (v: any, d = 0) => `${Math.max(0, Math.min(1, Number(v ?? d))) * 100}%` + const els: React.ReactNode[] = [] + const comps: any[] = Array.isArray(m.components) ? m.components : [] + comps.forEach((c, i) => { + if (i === 0) return // first band starts at the box top; a divider there is noise + els.push(
) + }) + comps.forEach((c, i) => els.push( + {c.label ?? i + 1} + )) + const boxes: any[] = Array.isArray(m.boxes) ? m.boxes : [] + boxes.forEach((b, i) => { + const rel = b.rel || {} + const filled = Number(b.fill ?? 0) > 0.35 + els.push(
) + }) + const fal = m.final_answer_line + if (fal && (fal.unit || fal.quantity)) { + els.push({[fal.quantity, fal.unit && `(${fal.unit})`].filter(Boolean).join(' ')}) + } + return els.length ? <>{els} : null +} + function renderShape(shape: ExamCanvasTLShape) { const kind = shape.props.kind const p = canvasShapePalette[kind] ?? canvasShapePalette.response @@ -190,7 +224,8 @@ function renderShape(shape: ExamCanvasTLShape) { aria-label={title} title={title} > - + {kind === 'response' && renderResponseSubstructure(shape)} + {shape.props.label || p.label} @@ -214,7 +249,7 @@ function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, 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), parentId: T.optional(T.string), isContainer: T.optional(T.boolean), depth: T.optional(T.number), description: T.optional(T.string), linkLabel: 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 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), parentId: T.optional(T.string), isContainer: T.optional(T.boolean), depth: T.optional(T.number), description: T.optional(T.string), linkLabel: T.optional(T.string), metaJson: 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.ts b/src/utils/exam-canvas/model.ts index 40d7c09..42a9b76 100644 --- a/src/utils/exam-canvas/model.ts +++ b/src/utils/exam-canvas/model.ts @@ -36,6 +36,9 @@ export interface ExamCanvasShapeModel { description?: string /** Label of the owning question, for a context region's visible tether pill. */ linkLabel?: string + /** Rich recognition detail (exam_response_areas.meta): answer components, MC option boxes, final-answer + * line unit/quantity, figure name/description. Carried through so the canvas can draw sub-structure. */ + meta?: Record | null source?: ExamTemplateSource confirmed?: boolean confidence?: number | null @@ -216,11 +219,13 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam || questions[0]?.id if (!questionId) continue const kind = region.kind as ExamCanvasRegionKind - // Carry the figure/table name + caption through the save so a named context region survives a round-trip - // (the replace endpoint persists exam_response_areas.meta). + // Carry rich recognition detail through the save so it survives a round-trip (the replace endpoint + // persists exam_response_areas.meta): answer components / MC boxes / final-answer for responses, and the + // figure name + caption for context regions. + const carried = (region.meta && typeof region.meta === 'object') ? region.meta as Record : undefined const meta = kind === 'context' && (region.label || region.description) - ? { name: region.label || undefined, description: region.description || undefined } - : undefined + ? { ...(carried ?? {}), name: region.label || carried?.name, description: region.description || carried?.description } + : carried 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, meta, source: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) }) } @@ -258,7 +263,7 @@ export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPag const label = r.kind === 'context' ? (name || (r.context_type && r.context_type !== 'generic' ? r.context_type : 'Context')) : (q ? `→ ${q.label}` : r.kind) - 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, description, linkLabel: q?.label, 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 }) + 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, description, linkLabel: q?.label, meta: (r as { meta?: Record }).meta ?? undefined, 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 addCheapReviewFlags(shapes, pages) }