Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
From KC's review — the canvas now draws the detail the spike detected:
- numbered sub-answer bands ('suggest two' → dashed dividers + 1/2 labels),
- per-option MC selection boxes (drawn from meta.boxes rel coords; filled tint
when detected marked),
- the final-answer line's expected quantity + unit pill.
Region meta (components/boxes/final_answer_line + figure name/desc) is carried
through load→shape props (metaJson)→render, and round-trips on save via
exam_response_areas.meta. tsc clean; 7/7 model tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
296 lines
18 KiB
TypeScript
296 lines
18 KiB
TypeScript
|
|
import type { ExamTemplateDetail, ExamTemplateSource, TemplateReplacePayload } from '../../types/exam.types'
|
|
|
|
export const PAGE_HEIGHT = 1100
|
|
export const PAGE_WIDTH = 780
|
|
export const PAGE_GAP = 0
|
|
|
|
export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number }
|
|
|
|
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
|
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
|
|
|
export interface CanvasBounds extends Record<string, number> { 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. */
|
|
id: string
|
|
kind: ExamCanvasShapeKind
|
|
x: number
|
|
y: number
|
|
w: number
|
|
h: number
|
|
label?: string
|
|
maxMarks?: number
|
|
answerType?: 'written' | 'mcq' | 'short' | 'diagram'
|
|
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
|
contextType?: string
|
|
questionId?: string | null
|
|
/** Tree parent (container question) id — preserved across a round-trip so N-deep structure survives a save. */
|
|
parentId?: string | null
|
|
/** True for a container question (main question / intermediate part) drawn as a nesting box. */
|
|
isContainer?: boolean
|
|
/** 0 = main question, 1 = part, 2 = subpart … drives nesting styling. */
|
|
depth?: number
|
|
/** Figure/table caption for a context region (shown as a tooltip). */
|
|
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<string, unknown> | null
|
|
source?: ExamTemplateSource
|
|
confirmed?: boolean
|
|
confidence?: number | null
|
|
derivation?: string | null
|
|
reviewFlags?: string[]
|
|
}
|
|
|
|
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
|
|
if (pages?.length) {
|
|
const hit = pages.find((page) => y >= page.y && y <= page.y + page.h)
|
|
if (hit) return hit.pageNumber
|
|
const nearest = pages.reduce((best, page) => {
|
|
const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h)))
|
|
return dy < best.dy ? { page, dy } : best
|
|
}, { page: pages[0], dy: Number.POSITIVE_INFINITY })
|
|
return nearest.page.pageNumber
|
|
}
|
|
return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
|
}
|
|
|
|
export function pageTop(page: number, pages?: CanvasPageGeometry[]): number {
|
|
const hit = pages?.find((p) => p.pageNumber === page)
|
|
return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP))
|
|
}
|
|
|
|
function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): number {
|
|
return pageForY(shape.y + shape.h / 2, pages)
|
|
}
|
|
|
|
export function isUuid(value: string | null | undefined): value is string {
|
|
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
}
|
|
|
|
export function newDomainId(): string {
|
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
return crypto.randomUUID()
|
|
}
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
const r = Math.floor(Math.random() * 16)
|
|
const v = c === 'x' ? r : (r % 4) + 8
|
|
return v.toString(16)
|
|
})
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
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 {
|
|
const ox2 = outer.x + outer.w
|
|
const oy2 = outer.y + outer.h
|
|
const ix2 = inner.x + inner.w
|
|
const iy2 = inner.y + inner.h
|
|
return inner.x >= outer.x && inner.y >= outer.y && ix2 <= ox2 && iy2 <= oy2
|
|
}
|
|
|
|
function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, shape: ExamCanvasShapeModel): boolean {
|
|
const minY = Math.min(top.y, bottom.y)
|
|
const maxY = Math.max(top.y, bottom.y)
|
|
const cy = shape.y + shape.h / 2
|
|
return cy >= minY && cy <= maxY
|
|
}
|
|
|
|
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload {
|
|
const orderedBoundaries = shapes
|
|
.filter((s) => s.kind === 'boundary')
|
|
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
|
|
// 'part' shapes carry the whole question tree: containers (isContainer) + leaf parts/subparts. They are the
|
|
// authoritative structure — the boundary-pair synthesis below is only a fallback for the manual workflow.
|
|
const structural = shapes.filter((s) => s.kind === 'part')
|
|
const containerShapes = structural.filter((s) => s.isContainer)
|
|
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
|
|
|
const questions: TemplateReplacePayload['questions'] = []
|
|
const boundaries: TemplateReplacePayload['boundaries'] = []
|
|
const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = []
|
|
|
|
// Stable question id per structural shape — prefer the persisted domain id so a re-save keeps join keys.
|
|
const idOf = new Map<string, string>()
|
|
for (const s of structural) idOf.set(s.id, isUuid(s.questionId) ? (s.questionId as string) : isUuid(s.id) ? s.id : newDomainId())
|
|
const emittedIds = new Set<string>(idOf.values())
|
|
|
|
// Boundary pairs: attach each pair to the container shape it spans (AI/structured path), else SYNTHESIZE a
|
|
// container question from the pair (pure manual authoring, no container box drawn). v1 always synthesized —
|
|
// which overwrote real containers and flattened the tree.
|
|
for (let i = 0; i < orderedBoundaries.length; i += 2) {
|
|
const top = orderedBoundaries[i]
|
|
const bottom = orderedBoundaries[i + 1]
|
|
if (!top || !bottom) break
|
|
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${bands.length + 1}`
|
|
// A container box whose vertical extent spans this start/end pair owns the boundary (structured path);
|
|
// otherwise the pair itself defines a container (manual path).
|
|
const pairTop = Math.min(top.y, bottom.y)
|
|
const pairBottom = Math.max(top.y, bottom.y)
|
|
const cover = containerShapes.find((c) => c.y <= pairTop + 4 && c.y + c.h >= pairBottom - 4)
|
|
let questionId: string
|
|
if (cover) {
|
|
questionId = idOf.get(cover.id) as string
|
|
} else {
|
|
questionId = isUuid(top.questionId) ? (top.questionId as string) : isUuid(bottom.questionId) ? (bottom.questionId as string) : newDomainId()
|
|
questions.push({ id: questionId, label, order: questions.length, max_marks: 0, is_container: true, mark_scheme: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) })
|
|
emittedIds.add(questionId)
|
|
}
|
|
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: persistedSource(b), confirmed: persistedConfirmed(b), confidence: persistedConfidence(b), derivation: persistedDerivation(b) })
|
|
}
|
|
}
|
|
|
|
// Resolve a structural shape's parent: the persisted parent (if still emitted) wins — this is what keeps a
|
|
// 3-deep subpart→part→question chain intact across a save. Manually-drawn shapes with no persisted parent
|
|
// fall back to geometry: the deepest container box that contains them, else the boundary band around them.
|
|
const resolveParent = (s: ExamCanvasShapeModel): string | null => {
|
|
if (isUuid(s.parentId) && emittedIds.has(s.parentId as string)) return s.parentId as string
|
|
const containing = containerShapes
|
|
.filter((c) => c.id !== s.id && contains(bounds(c), bounds(s)))
|
|
.sort((a, b) => (b.depth ?? 0) - (a.depth ?? 0) || (a.w * a.h) - (b.w * b.h))
|
|
if (containing.length) return idOf.get(containing[0].id) ?? null
|
|
const band = bands.find((bd) => bandContains(bd.top, bd.bottom, s))
|
|
return band?.questionId ?? null
|
|
}
|
|
|
|
const partQuestionIds = new Map<string, string>()
|
|
const structuralSorted = [...structural].sort((a, b) => (a.depth ?? 0) - (b.depth ?? 0) || (a.y - b.y) || (a.x - b.x))
|
|
structuralSorted.forEach((part, index) => {
|
|
const qid = idOf.get(part.id) as string
|
|
partQuestionIds.set(part.id, qid)
|
|
questions.push({ id: qid, parent_id: resolveParent(part), label: part.label || (part.isContainer ? `Q${index + 1}` : `Part ${index + 1}`), order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.isContainer ? null : (part.answerType ?? 'written'), mcq_options: null, mark_scheme: {}, is_container: !!part.isContainer, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
|
|
})
|
|
const parts = structural
|
|
|
|
// Resolve each region's owner question. Order: current geometric containment (a user who drags a
|
|
// region into a part re-attaches it) → the PERSISTED attachment if it still points at a saved
|
|
// question (survives pixel-overflow drift and regions with no enclosing part) → nearest part on the
|
|
// page → first part → first question of any kind. exam_response_areas.question_id is NOT NULL, so we
|
|
// never silently drop a region while any question exists (only a template with zero questions can).
|
|
const questionIds = new Set(questions.map((q) => q.id))
|
|
const response_areas: TemplateReplacePayload['response_areas'] = []
|
|
// Prefer the DEEPEST (smallest) containing structural box so a region lands on its leaf part, not the
|
|
// enclosing container. Leaf parts win over containers at equal containment.
|
|
const deepestContainer = (region: ExamCanvasShapeModel): ExamCanvasShapeModel | undefined =>
|
|
parts
|
|
.filter((part) => part.id !== region.id && contains(bounds(part), bounds(region)))
|
|
.sort((a, b) => Number(!!a.isContainer) - Number(!!b.isContainer) || (a.w * a.h) - (b.w * b.h))[0]
|
|
for (const region of regions) {
|
|
const containingPart = deepestContainer(region)
|
|
const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined
|
|
const nearestLeaf = parts.filter((p) => !p.isContainer).find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts.find((p) => !p.isContainer) ?? parts[0]
|
|
const questionId =
|
|
(containingPart && partQuestionIds.get(containingPart.id))
|
|
|| persisted
|
|
|| (nearestLeaf && partQuestionIds.get(nearestLeaf.id))
|
|
|| questions[0]?.id
|
|
if (!questionId) continue
|
|
const kind = region.kind as ExamCanvasRegionKind
|
|
// 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<string, unknown> : undefined
|
|
const meta = kind === 'context' && (region.label || region.description)
|
|
? { ...(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) })
|
|
}
|
|
|
|
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[] {
|
|
const shapes: ExamCanvasShapeModel[] = []
|
|
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
|
const depthOf = (id: string | null | undefined, guard = 0): number => {
|
|
const q = id ? questions.get(id) : undefined
|
|
return !q || !q.parent_id || guard > 8 ? 0 : 1 + depthOf(q.parent_id, guard + 1)
|
|
}
|
|
for (const b of detail.boundaries ?? []) {
|
|
const page = pageGeometry((b.page_index ?? 0) + 1, pages)
|
|
// 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 })
|
|
}
|
|
// Draw the WHOLE tree that has geometry: containers (main questions / intermediate parts) AND leaf parts,
|
|
// each carrying its parent id + depth so nesting renders and a save preserves the N-deep chain. A question
|
|
// with no bounds still can't be drawn (legacy data) — it falls back to boundary-band synthesis on save.
|
|
for (const q of detail.questions ?? []) {
|
|
if (!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.is_container ? undefined : ((q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written'), questionId: q.id, parentId: q.parent_id, isContainer: q.is_container, depth: depthOf(q.id), source: q.source, confirmed: q.confirmed, confidence: q.confidence, derivation: q.derivation })
|
|
}
|
|
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)
|
|
const meta = ((r as { meta?: Record<string, unknown> }).meta) ?? {}
|
|
const name = typeof meta.name === 'string' ? meta.name : undefined
|
|
const description = typeof meta.description === 'string' ? meta.description : undefined
|
|
// A context region is NAMED by its figure/table (was an anonymous purple box). Response/other regions keep
|
|
// the "→ owning question" label so their link is legible.
|
|
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, meta: (r as { meta?: Record<string, unknown> }).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)
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|