diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index 8f0b9e3..535b821 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -20,6 +20,7 @@ import type { ExamResponseArea, ExamTemplate, ExamTemplateDetail, + ExamTemplateLayout, MarkingBatch, MarkUpsertPayload, Neo4jSyncResult, @@ -64,6 +65,10 @@ function questionPayload(q: ExamQuestion, idMap?: Map) { spec_ref: q.spec_ref, bounds: q.bounds ?? null, page: q.page ?? null, + source: q.source ?? 'manual', + confirmed: q.confirmed ?? true, + confidence: q.confidence ?? null, + derivation: q.derivation ?? null, }; } @@ -79,6 +84,8 @@ function responseAreaPayload(r: ExamResponseArea, idMap?: Map, d source: r.source, confirmed: r.confirmed, confidence: r.confidence, + mark_subtype: r.mark_subtype ?? null, + derivation: r.derivation ?? null, }; } @@ -92,6 +99,26 @@ function boundaryPayload(b: ExamBoundary, idMap?: Map, duplicate bounds: b.bounds, source: b.source, 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 +140,7 @@ async function replaceTemplate( questions: detail.questions.map((q) => questionPayload(q, idMap)), response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)), boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)), + layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)), }, { headers }, ); diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 1b65186..17d08e9 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -75,6 +75,8 @@ export interface UpdateTemplateMetaPayload { } /** Canvas children (used from S4-9 onward; defined here so the seam is complete). */ +export type ExamTemplateSource = 'manual' | 'ai'; + export interface ExamQuestion { id: string; template_id: string; @@ -89,6 +91,10 @@ export interface ExamQuestion { spec_ref: string | null; bounds?: Record | null; page?: number | null; + source: ExamTemplateSource; + confirmed: boolean; + confidence: number | null; + derivation: string | null; } export type ExamResponseAreaKind = @@ -99,6 +105,8 @@ export type ExamResponseAreaKind = | 'reference' | 'furniture'; +export type ExamMarkSubtype = 'part_marks' | 'question_total' | 'grader_box'; + export interface ExamResponseArea { id: string; question_id: string; @@ -108,9 +116,11 @@ export interface ExamResponseArea { kind: ExamResponseAreaKind; response_form: string | null; context_type?: string | null; - source: 'manual' | 'ai'; + source: ExamTemplateSource; confirmed: boolean; confidence: number | null; + mark_subtype?: ExamMarkSubtype | null; + derivation?: string | null; } export interface ExamBoundary { @@ -121,14 +131,36 @@ export interface ExamBoundary { page_index: number; y: number; bounds: Record | null; - source: 'manual' | 'ai'; + source: ExamTemplateSource; 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; + created_at?: string; + updated_at?: string; } export interface ExamTemplateDetail extends ExamTemplate { questions: ExamQuestion[]; response_areas: ExamResponseArea[]; boundaries: ExamBoundary[]; + layout: ExamTemplateLayout[]; } @@ -152,6 +184,10 @@ export interface TemplateReplacePayload { spec_ref?: string | null; bounds?: Record | null; page?: number | null; + source?: ExamTemplateSource; + confirmed?: boolean; + confidence?: number | null; + derivation?: string | null; }>; response_areas: Array<{ id?: string; @@ -164,6 +200,8 @@ export interface TemplateReplacePayload { source?: 'manual' | 'ai'; confirmed?: boolean; confidence?: number | null; + mark_subtype?: ExamMarkSubtype | null; + derivation?: string | null; }>; boundaries: Array<{ id?: string; @@ -172,8 +210,25 @@ export interface TemplateReplacePayload { page_index: number; y: number; bounds?: Record | null; - source?: 'manual' | 'ai'; + source?: ExamTemplateSource; 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; }>; } diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts index 52b40b7..7d83797 100644 --- a/src/utils/exam-canvas/model.test.ts +++ b/src/utils/exam-canvas/model.test.ts @@ -4,7 +4,7 @@ import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './m const template: ExamTemplateDetail = { 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', () => { @@ -50,14 +50,14 @@ describe('exam setup canvas serialization', () => { const shapes = shapesFromTemplate({ ...template, 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: '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: '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, source: 'manual', confirmed: true, confidence: null, derivation: null }, ], 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: '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: '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, 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.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 }) diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts index 22bdb54..c13235d 100644 --- a/src/utils/exam-canvas/model.ts +++ b/src/utils/exam-canvas/model.ts @@ -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 ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind -export interface CanvasBounds { x: number; y: number; w: number; h: number } +export interface CanvasBounds extends Record { x: number; y: number; w: number; h: number } export interface ExamCanvasShapeModel { /** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */ @@ -102,10 +102,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: {} }) + questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {}, source: 'manual', confirmed: true, confidence: null, derivation: null }) 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: 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: bounds(b), source: 'manual', confirmed: true, confidence: null, derivation: null }) } } @@ -114,7 +114,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) }) + 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: 'manual', confirmed: true, confidence: null, derivation: null }) }) const response_areas: TemplateReplacePayload['response_areas'] = [] @@ -124,10 +124,10 @@ 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: '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: 'manual', confirmed: true, confidence: null, mark_subtype: null, derivation: null }) } - 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[] { @@ -139,12 +139,12 @@ export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPag } for (const q of detail.questions ?? []) { 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 }) } for (const r of detail.response_areas ?? []) { const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 } 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 }) } return shapes }