import * as errors from "@superbuilders/errors" import * as logger from "@superbuilders/slog" import { escapeXmlAttribute } from "@/compiler/utils/xml-utils" import type { FeedbackContent } from "@/core/content" import type { FeedbackDimension, FeedbackPlan } from "@/core/feedback" import type { AssessmentItem } from "@/core/item" // Internal type for compilation after nested feedback has been flattened type AssessmentItemWithFeedbackBlocks = Omit< AssessmentItem, "feedback" > & { feedbackBlocks: Record> } function buildCorrectComparison( item: AssessmentItemWithFeedbackBlocks, responseIdentifier: string ): string { const escapedId = escapeXmlAttribute(responseIdentifier) const decl = item.responseDeclarations.find( (d) => d.identifier === responseIdentifier ) if (!decl) { logger.error("response declaration not found for comparison", { responseIdentifier }) return `` } if (decl.cardinality === "single") { if (decl.baseType === "float") { const { rounding } = decl const roundingMode = rounding.strategy === "decimalPlaces" ? "decimalPlaces" : "significantFigures" const figures = String(rounding.figures) return ` ` } if (decl.baseType === "integer") { return ` ` } } return `` } export function compileResponseDeclarations( decls: AssessmentItem["responseDeclarations"] ): string { return decls .map((decl): string => { const formatCorrectValue = (value: unknown): string => { if (decl.baseType === "integer" || decl.baseType === "float") { if (typeof value !== "number") { logger.error("numeric response has non-number correct value", { identifier: decl.identifier, baseType: decl.baseType, value }) throw errors.new( `numeric response correct value must be a number for ${decl.identifier}` ) } return value.toString() } return String(value) } // Handle directedPair base type separately if (decl.baseType === "directedPair") { // Type narrowing: when baseType is "directedPair", correct is an array of {source, target} objects // Using type guard to safely access the correct property structure if (!Array.isArray(decl.correct)) { logger.error("directedPair response missing array", { identifier: decl.identifier, correctType: typeof decl.correct }) throw errors.new( "directedPair response must have array of correct values" ) } const pairs = decl.correct const correctXml = pairs .map((p: unknown): string => { if (typeof p !== "object" || p === null) { logger.error("invalid directedPair correct value", { identifier: decl.identifier, value: p }) throw errors.new("invalid directedPair correct value structure") } const sourceDesc = Object.getOwnPropertyDescriptor(p, "source") const targetDesc = Object.getOwnPropertyDescriptor(p, "target") if (!sourceDesc || !targetDesc) { logger.error("invalid directedPair correct value", { identifier: decl.identifier, value: p }) throw errors.new("invalid directedPair correct value structure") } const source = String(sourceDesc.value) const target = String(targetDesc.value) return `${escapeXmlAttribute(source)} ${escapeXmlAttribute(target)}` }) .join("\n ") // Add mapping for partial credit (1 point per correct association) const mappingXml = pairs .map((p: unknown): string => { if (typeof p !== "object" || p === null) { logger.error("invalid directedPair value for mapping", { identifier: decl.identifier, value: p }) throw errors.new("invalid directedPair correct value for mapping") } const sourceDesc = Object.getOwnPropertyDescriptor(p, "source") const targetDesc = Object.getOwnPropertyDescriptor(p, "target") if (!sourceDesc || !targetDesc) { logger.error("invalid directedPair value for mapping", { identifier: decl.identifier, value: p }) throw errors.new("invalid directedPair correct value for mapping") } const source = String(sourceDesc.value) const target = String(targetDesc.value) return `\n ` }) .join("") return `\n ${correctXml} ${mappingXml} ` } // Handle other base types (original code) const correctValues = Array.isArray(decl.correct) ? decl.correct : [decl.correct] // Directly map the provided correct values without generating any alternatives. const correctXml = correctValues .map( (v: unknown): string => `${formatCorrectValue(v) .replace(/&/g, "&") .replace(//g, ">")}` ) .join("\n ") let xml = `\n ${correctXml} ` // For single-cardinality responses, also emit a mapping that awards 1 point. const isSingleResponse = decl.cardinality === "single" && (decl.baseType === "string" || decl.baseType === "integer" || decl.baseType === "float") if (isSingleResponse) { const mappingXml = correctValues .map((v: unknown): string => { const key = typeof v === "string" || typeof v === "number" ? formatCorrectValue(v) : "" return `\n ` }) .join("") xml += `\n ${mappingXml}\n ` } xml += "\n " return xml }) .join("") } function generateComboModeProcessing( item: AssessmentItemWithFeedbackBlocks ): string { const { dimensions, combinations } = item.feedbackPlan function buildConditionTree( dims: FeedbackDimension[], pathSegments: Array<{ responseIdentifier: string; key: string }> ): string { if (dims.length === 0) { const matchingCombo = combinations.find( (combo: FeedbackPlan["combinations"][number]): boolean => { if (combo.path.length !== pathSegments.length) return false return combo.path.every( ( seg: FeedbackPlan["combinations"][number]["path"][number], i: number ): boolean => seg.responseIdentifier === pathSegments[i].responseIdentifier && seg.key === pathSegments[i].key ) } ) if (!matchingCombo) { logger.error("no combination found for path", { pathSegments }) throw errors.new("no combination found for path") } return ` ${escapeXmlAttribute(matchingCombo.id)} ` } const [currentDim, ...restDims] = dims if (!currentDim) { logger.error("unexpected empty dimension in condition tree") throw errors.new("unexpected empty dimension") } const responseId = escapeXmlAttribute(currentDim.responseIdentifier) if (currentDim.kind === "enumerated") { const conditions = currentDim.keys .map((key, index): string => { const tag = index === 0 ? "qti-response-if" : "qti-response-else-if" const choiceId = escapeXmlAttribute(key) const newPathSegments = [ ...pathSegments, { responseIdentifier: currentDim.responseIdentifier, key } ] const innerContent = buildConditionTree(restDims, newPathSegments) return ` <${tag}> ${choiceId} ${innerContent} ` }) .join("") return ` ${conditions} ` } const correctPath = [ ...pathSegments, { responseIdentifier: currentDim.responseIdentifier, key: "CORRECT" } ] const incorrectPath = [ ...pathSegments, { responseIdentifier: currentDim.responseIdentifier, key: "INCORRECT" } ] const correctBranch = buildConditionTree(restDims, correctPath) const incorrectBranch = buildConditionTree(restDims, incorrectPath) const correctComparison = buildCorrectComparison( item, currentDim.responseIdentifier ) return ` ${correctComparison}${correctBranch} ${incorrectBranch} ` } return buildConditionTree(dimensions, []) } function generateFallbackModeProcessing( item: AssessmentItemWithFeedbackBlocks ): string { if (item.feedbackPlan.dimensions.length === 0) { logger.error("no dimensions for fallback mode processing", { itemIdentifier: item.identifier }) throw errors.new("fallback mode requires at least one dimension") } const matchConditions = item.feedbackPlan.dimensions.map( (dim: FeedbackDimension): string => buildCorrectComparison(item, dim.responseIdentifier) ) const allCorrectCondition = matchConditions.length === 1 ? matchConditions[0] : ` ${matchConditions.join("\n ")} ` return ` ${allCorrectCondition} CORRECT INCORRECT ` } export function compileResponseProcessing( item: AssessmentItemWithFeedbackBlocks ): string { const processingRules: string[] = [] const { feedbackPlan } = item logger.info("compiling response processing", { mode: feedbackPlan.mode, itemIdentifier: item.identifier, dimensionCount: feedbackPlan.dimensions.length }) if (feedbackPlan.mode === "combo") { processingRules.push(generateComboModeProcessing(item)) } else { processingRules.push(generateFallbackModeProcessing(item)) } const scoreConditions = item.responseDeclarations .map((decl): string => { return buildCorrectComparison(item, decl.identifier) }) .join("\n ") processingRules.push(` ${scoreConditions} 1.0 0.0 `) return ` ${processingRules.join("\n")} ` }