import * as errors from "@superbuilders/errors"
import * as logger from "@superbuilders/slog"
import { z } from "zod"
import type { FeedbackContent } from "@/core/content"
import { createFeedbackContentSchema } from "@/core/content/contextual-schemas"
import type {
AuthoringFeedbackOverall,
AuthoringNestedLeaf,
AuthoringNestedNode
} from "@/core/feedback/authoring/types"
import type { FeedbackPlan } from "@/core/feedback/plan"
import { buildFeedbackPlanFromInteractions } from "@/core/feedback/plan"
import type { AnyInteraction } from "@/core/interactions"
import type { ResponseDeclaration } from "@/core/item"
// --- Error Constants ---
export const ErrFeedbackMissingOverall = errors.new(
"feedback must contain FEEDBACK__OVERALL"
)
export const ErrFeedbackOverallNotObject = errors.new(
"FEEDBACK__OVERALL must be an object"
)
export const ErrFeedbackLeafAtRoot = errors.new(
"feedback leaf node cannot be at the root"
)
export const ErrFeedbackInvalidNode = errors.new(
"feedback tree contains non-object node"
)
export const ErrFeedbackNoCombinationForPath = errors.new(
"no combination found for path"
)
export const ErrFeedbackExtraKeys = errors.new("feedback contains extra keys")
export const ErrFeedbackMissingLeaves = errors.new(
"feedback is missing required leaves"
)
export const ErrFeedbackSchemaValidation = errors.new(
"feedback schema validation"
)
// --- Nested Schema Builder ---
export function createFeedbackObjectSchema<
P extends FeedbackPlan,
const E extends readonly string[]
>(
feedbackPlan: P,
widgetTypeKeys: E
): z.ZodType<{ FEEDBACK__OVERALL: AuthoringFeedbackOverall
}> {
const FeedbackContentSchema: z.ZodType> =
createFeedbackContentSchema(widgetTypeKeys)
const LeafNodeSchema: z.ZodType> = z
.object({ content: FeedbackContentSchema })
.strict()
let OverallSchema: z.ZodType>
if (feedbackPlan.mode === "fallback") {
OverallSchema = z
.object({
CORRECT: LeafNodeSchema,
INCORRECT: LeafNodeSchema
})
.strict()
} else {
type LeafT = AuthoringNestedLeaf
type NodeT = AuthoringNestedNode
if (feedbackPlan.dimensions.length === 0) {
logger.error("combo feedback plan has no dimensions", {
dimensionCount: 0
})
throw errors.new("combo feedback plan has no dimensions")
}
let current: z.ZodType = LeafNodeSchema
for (let i = feedbackPlan.dimensions.length - 1; i >= 0; i--) {
const dimension = feedbackPlan.dimensions[i]
if (!dimension) {
logger.error("undefined dimension in feedback plan", {
index: i,
dimensionCount: feedbackPlan.dimensions.length
})
throw errors.new("undefined dimension in feedback plan")
}
const keys: readonly string[] =
dimension.kind === "enumerated"
? dimension.keys
: ["CORRECT", "INCORRECT"]
const innerShape: Record> = {}
for (const key of keys) {
innerShape[key] = current
}
const inner = z.object(innerShape).strict()
const branchShape: Record = {
[dimension.responseIdentifier]: inner
}
const branch = z.object(branchShape).strict()
current = branch
}
// biome-ignore lint: intentional narrowing
OverallSchema = current as z.ZodType
}
const FeedbackObjectSchema = z
.object({ FEEDBACK__OVERALL: OverallSchema })
.strict()
return FeedbackObjectSchema
}
// --- Validation and Conversion ---
export function validateFeedbackObject<
P extends FeedbackPlan,
const E extends readonly string[]
>(
feedbackObject: unknown,
feedbackPlan: P,
widgetTypeKeys: E
): { FEEDBACK__OVERALL: AuthoringFeedbackOverall } {
const schema = createFeedbackObjectSchema(feedbackPlan, widgetTypeKeys)
const result = schema.safeParse(feedbackObject)
if (!result.success) {
logger.error("feedback object validation", { error: result.error })
throw errors.wrap(result.error, "feedback object validation")
}
return result.data
}
export function convertFeedbackObjectToBlocks<
P extends FeedbackPlan,
E extends readonly string[]
>(
feedbackObject: {
FEEDBACK__OVERALL: AuthoringFeedbackOverall
},
feedbackPlan: P
): Record> {
const blocks: Record> = {}
const overallFeedback = feedbackObject.FEEDBACK__OVERALL
if (!overallFeedback || typeof overallFeedback !== "object") {
logger.error("FEEDBACK__OVERALL is not an object during conversion", {
overallFeedback
})
throw ErrFeedbackOverallNotObject
}
type FallbackOverall = {
CORRECT: AuthoringNestedLeaf
INCORRECT: AuthoringNestedLeaf
}
const isLeaf = (node: unknown): node is AuthoringNestedLeaf => {
if (typeof node !== "object" || node === null) return false
return Object.hasOwn(node, "content")
}
const isFallbackOverall = (node: unknown): node is FallbackOverall => {
if (typeof node !== "object" || node === null) return false
const c = Reflect.get(node, "CORRECT")
const i = Reflect.get(node, "INCORRECT")
return isLeaf(c) && isLeaf(i)
}
const isNestedNode = (
node: AuthoringFeedbackOverall
): node is AuthoringNestedNode => {
return !("CORRECT" in node && "INCORRECT" in node)
}
if (feedbackPlan.mode === "fallback") {
if (!isFallbackOverall(overallFeedback)) {
logger.error("invalid overall feedback shape for fallback", {
overallFeedback
})
throw errors.new("invalid overall fallback feedback shape")
}
blocks.CORRECT = overallFeedback.CORRECT.content
blocks.INCORRECT = overallFeedback.INCORRECT.content
return blocks
}
const walk = (
node: AuthoringNestedNode | AuthoringNestedLeaf,
path: Array<{ responseIdentifier: string; key: string }>
): void => {
if (isLeaf(node)) {
if (path.length === 0) {
logger.error("leaf node found at root of feedback object", { node })
throw ErrFeedbackLeafAtRoot
}
const combination = feedbackPlan.combinations.find(
(c: FeedbackPlan["combinations"][number]) =>
c.path.length === path.length &&
c.path.every(
(
seg: FeedbackPlan["combinations"][number]["path"][number],
i: number
) =>
path[i] !== undefined &&
seg.responseIdentifier === path[i].responseIdentifier &&
seg.key === path[i].key
)
)
if (!combination) {
logger.error("no combination found for path", { pathSegments: path })
throw ErrFeedbackNoCombinationForPath
}
blocks[combination.id] = node.content
return
}
const branch = node
for (const [responseIdentifier, keyed] of Object.entries(branch)) {
if (typeof keyed !== "object" || keyed === null) {
logger.error("invalid child node in feedback tree", {
keyed,
pathSegments: path
})
throw ErrFeedbackInvalidNode
}
for (const [subKey, subChild] of Object.entries(keyed)) {
walk(subChild, [...path, { responseIdentifier, key: subKey }])
}
}
}
if (!isNestedNode(overallFeedback)) {
logger.error("expected nested node in combo mode but got fallback shape")
throw errors.new("expected nested node in combo mode")
}
walk(overallFeedback, [])
const producedIds = new Set(Object.keys(blocks))
const expectedIds = new Set(
feedbackPlan.combinations.map(
(c: FeedbackPlan["combinations"][number]) => c.id
)
)
if (producedIds.size > expectedIds.size) {
const extraKeys = [...producedIds].filter((id) => !expectedIds.has(id))
logger.error("feedback contains extra keys", {
dimensionCount: feedbackPlan.dimensions.length,
combinationCount: feedbackPlan.combinations.length,
extraKeys: extraKeys.slice(0, 5)
})
throw ErrFeedbackExtraKeys
}
if (producedIds.size < expectedIds.size) {
const missingKeys = [...expectedIds].filter(
(id: string) => !producedIds.has(id)
)
logger.error("feedback is missing required leaves", {
dimensionCount: feedbackPlan.dimensions.length,
combinationCount: feedbackPlan.combinations.length,
missingKeys: missingKeys.slice(0, 5)
})
throw ErrFeedbackMissingLeaves
}
return blocks
}
export function buildEmptyNestedFeedback<
P extends FeedbackPlan,
E extends readonly string[] = readonly string[]
>(
feedbackPlan: P
): { FEEDBACK__OVERALL: AuthoringFeedbackOverall } {
function buildNode(
dims: readonly FeedbackPlan["dimensions"][number][]
): AuthoringNestedNode {
if (dims.length === 0) {
logger.error("no dimensions to build nested node")
throw errors.new("no dimensions to build nested node")
}
const [currentDim, ...restDims] = dims
const keys =
currentDim.kind === "enumerated"
? currentDim.keys
: ["CORRECT", "INCORRECT"]
const childIsLeaf = restDims.length === 0
const child: AuthoringNestedLeaf | AuthoringNestedNode =
childIsLeaf
? {
content: {
preamble: { correctness: "incorrect", summary: [] },
steps: []
}
}
: buildNode(restDims)
const branch: Record = {}
for (const key of keys) {
branch[key] = child
}
return { [currentDim.responseIdentifier]: branch }
}
const overallFeedback: AuthoringFeedbackOverall =
feedbackPlan.mode === "fallback"
? {
CORRECT: {
content: {
preamble: { correctness: "correct", summary: [] },
steps: []
}
},
INCORRECT: {
content: {
preamble: { correctness: "incorrect", summary: [] },
steps: []
}
}
}
: buildNode(feedbackPlan.dimensions)
return {
FEEDBACK__OVERALL: overallFeedback
}
}
export function buildFeedbackFromNestedForTemplate<
const E extends readonly string[]
>(
interactions: Record>,
responseDeclarations: ResponseDeclaration[],
feedbackObject: {
FEEDBACK__OVERALL: AuthoringFeedbackOverall
},
widgetTypeKeys: E
): {
feedbackPlan: FeedbackPlan
feedbackBlocks: Record>
} {
const plan = buildFeedbackPlanFromInteractions(
interactions,
responseDeclarations
)
validateFeedbackObject(feedbackObject, plan, widgetTypeKeys)
const blocks = convertFeedbackObjectToBlocks(feedbackObject, plan)
return { feedbackPlan: plan, feedbackBlocks: blocks }
}
// Flat schema and types DEPRECATED and removed from public exports
// Nested-only feedback authoring is the single canonical representation