WS-2 R2: render answer sub-structure on response boxes (components/MC boxes/units)
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
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
This commit is contained in:
parent
e0e36225fe
commit
81e1b5ed25
@ -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('|') },
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, any> | 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(<div key={`cd${i}`} style={{ position: 'absolute', left: 0, right: 0, top: pct(c.rel_y0), borderTop: '1px dashed var(--exam-stroke)', opacity: 0.45 }} />)
|
||||
})
|
||||
comps.forEach((c, i) => els.push(
|
||||
<span key={`cl${i}`} className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 4, top: `calc(${pct(c.rel_y0)} + 2px)`, fontSize: 10, fontWeight: 900, borderRadius: 6, padding: '0 5px', opacity: 0.95 }}>{c.label ?? i + 1}</span>
|
||||
))
|
||||
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(<div key={`bx${i}`} title={`option ${i + 1}${filled ? ' (marked)' : ''}`} style={{ position: 'absolute', left: pct(rel.x0), top: pct(rel.y0), width: pct(Number(rel.x1) - Number(rel.x0)), height: pct(Number(rel.y1) - Number(rel.y0)), border: '1.5px solid var(--exam-stroke)', borderRadius: 3, background: filled ? 'var(--exam-stroke)' : 'transparent', opacity: filled ? 0.5 : 0.85, boxSizing: 'border-box' }} />)
|
||||
})
|
||||
const fal = m.final_answer_line
|
||||
if (fal && (fal.unit || fal.quantity)) {
|
||||
els.push(<span key="fal" className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 4, top: `calc(${pct(fal.rel_y, 0.9)} - 8px)`, maxWidth: 'calc(100% - 8px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 10, fontWeight: 800, borderRadius: 6, padding: '0 5px', opacity: 0.95 }} title="expected final answer">{[fal.quantity, fal.unit && `(${fal.unit})`].filter(Boolean).join(' ')}</span>)
|
||||
}
|
||||
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}
|
||||
>
|
||||
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, maxWidth: 'calc(100% - 60px)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{kind === 'response' && renderResponseSubstructure(shape)}
|
||||
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, maxWidth: 'calc(100% - 60px)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', zIndex: 1 }}>
|
||||
<span aria-hidden="true">{isAiSuggestion ? 'AI' : (isContainer ? (depth === 0 ? '▣' : '▢') : p.icon)}</span>
|
||||
{shape.props.label || p.label}
|
||||
</span>
|
||||
@ -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) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||||
|
||||
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
||||
|
||||
@ -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<string, unknown> | 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<string, unknown> : 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<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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user