S5-7: basic G6 review wiring (dashed/translucent AI shapes, confidence, flags)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
|
||||
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
|
||||
import type { ExamTemplateDetail, ExamTemplateSource, TemplateReplacePayload } from '../../types/exam.types'
|
||||
|
||||
export const PAGE_HEIGHT = 1100
|
||||
export const PAGE_WIDTH = 780
|
||||
@@ -26,6 +26,11 @@ export interface ExamCanvasShapeModel {
|
||||
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
||||
contextType?: string
|
||||
questionId?: string | null
|
||||
source?: ExamTemplateSource
|
||||
confirmed?: boolean
|
||||
confidence?: number | null
|
||||
derivation?: string | null
|
||||
reviewFlags?: string[]
|
||||
}
|
||||
|
||||
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
|
||||
@@ -78,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
|
||||
@@ -111,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: 'manual', confirmed: true, confidence: null, 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: 'manual', confirmed: true, confidence: null, 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) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,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: 'manual', confirmed: true, confidence: null, 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'] = []
|
||||
@@ -133,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: 'manual', confirmed: true, confidence: null, mark_subtype: null, 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 ?? [] }
|
||||
@@ -146,16 +167,42 @@ export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPag
|
||||
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 })
|
||||
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 })
|
||||
}
|
||||
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 as ExamCanvasShapeModel['answerType']) ?? '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, 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)
|
||||
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 })
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user