diff --git a/apps/web/src/app/flowchart/workshop/[sessionId]/page.tsx b/apps/web/src/app/flowchart/workshop/[sessionId]/page.tsx index b034b7fe..4e9d3f01 100644 --- a/apps/web/src/app/flowchart/workshop/[sessionId]/page.tsx +++ b/apps/web/src/app/flowchart/workshop/[sessionId]/page.tsx @@ -86,6 +86,7 @@ export default function WorkshopPage() { const [executableFlowchart, setExecutableFlowchart] = useState(null) const [isExportingPDF, setIsExportingPDF] = useState(false) const [showCreatePdfModal, setShowCreatePdfModal] = useState(false) + const [highlightedNodeId, setHighlightedNodeId] = useState(null) // Examples for worksheet generation const [worksheetExamples, setWorksheetExamples] = useState([]) @@ -390,11 +391,11 @@ export default function WorkshopPage() { state: 'refining', draftDefinitionJson: data.draftDefinitionJson || JSON.stringify(parsedDefinition), - draftMermaidContent: mermaidContent, - draftTitle: title, - draftDescription: description, - draftDifficulty: difficulty, - draftEmoji: emoji, + draftMermaidContent: mermaidContent ?? null, + draftTitle: title ?? null, + draftDescription: description ?? null, + draftDifficulty: difficulty ?? null, + draftEmoji: emoji ?? null, draftNotes: data.draftNotes || JSON.stringify(parsedNotes), currentReasoningText: null, // Clear on completion } @@ -609,6 +610,21 @@ export default function WorkshopPage() { } }, [refinementText, selectedDiagnostics, sessionId]) + // Handler for updating the definition directly (e.g., adding test cases) + const handleUpdateDefinition = useCallback( + (updatedDefinition: FlowchartDefinition) => { + setSession((prev) => + prev + ? { + ...prev, + draftDefinitionJson: JSON.stringify(updatedDefinition), + } + : null + ) + }, + [] + ) + // Helper to check if two diagnostics are the same // Must compare code, location, AND message because multiple diagnostics // can have the same code and location (e.g., two unknown refs in same field) @@ -1172,6 +1188,7 @@ export default function WorkshopPage() { @@ -1238,7 +1255,11 @@ export default function WorkshopPage() { {activeTab === 'structure' && } {activeTab === 'input' && } {activeTab === 'tests' && ( - + )} {activeTab === 'worksheet' && executableFlowchart && (
@@ -1269,7 +1290,11 @@ export default function WorkshopPage() { Create PDF Worksheet {/* Debug Panel - shows generated examples with answers */} - +
)} {activeTab === 'worksheet' && !executableFlowchart && ( diff --git a/apps/web/src/components/flowchart/DebugMermaidDiagram.tsx b/apps/web/src/components/flowchart/DebugMermaidDiagram.tsx index f30b2cb0..0f40a537 100644 --- a/apps/web/src/components/flowchart/DebugMermaidDiagram.tsx +++ b/apps/web/src/components/flowchart/DebugMermaidDiagram.tsx @@ -6,8 +6,10 @@ import { css } from '../../../styled-system/css' interface DebugMermaidDiagramProps { /** Raw mermaid content */ mermaidContent: string - /** Current node ID to highlight */ - currentNodeId: string + /** Current node ID to highlight (amber fill - walker progress) */ + currentNodeId?: string + /** Highlighted node ID for trace hover (cyan border - distinct from current) */ + highlightedNodeId?: string /** Callback when regeneration is requested (shown when there's a render error) */ onRegenerate?: () => void /** Whether regeneration is currently in progress */ @@ -23,6 +25,7 @@ interface DebugMermaidDiagramProps { export function DebugMermaidDiagram({ mermaidContent, currentNodeId, + highlightedNodeId, onRegenerate, isRegenerating, }: DebugMermaidDiagramProps) { @@ -63,12 +66,13 @@ export function DebugMermaidDiagram({ .replace(/\\"/g, "'") // Convert \" to ' .replace(/\\'/g, "'") // Convert \' to ' - // Add style definition to highlight the current node (only if a node ID is provided) - // We append this to the mermaid content - const highlightStyle = currentNodeId - ? `\n style ${currentNodeId} fill:#fbbf24,stroke:#d97706,stroke-width:4px,color:#000` - : '' - const contentWithHighlight = sanitizedContent + highlightStyle + // Add style definitions for current node highlighting (walker progress) + let highlightStyles = '' + if (currentNodeId) { + highlightStyles += `\n style ${currentNodeId} fill:#fbbf24,stroke:#d97706,stroke-width:4px,color:#000` + } + + const contentWithHighlight = sanitizedContent + highlightStyles // Render the diagram const { svg } = await mermaid.render(id, contentWithHighlight) @@ -81,6 +85,30 @@ export function DebugMermaidDiagram({ if (svgElement) { svgElement.style.maxWidth = '100%' svgElement.style.height = 'auto' + + // Apply highlighted node style post-render + if (highlightedNodeId && highlightedNodeId !== currentNodeId) { + // Dim all nodes + const allNodes = svgElement.querySelectorAll('[id^="flowchart-"]') + allNodes.forEach((node) => { + ;(node as SVGElement).style.opacity = '0.85' + }) + + // Highlight the target node + const nodeElement = svgElement.querySelector(`[id*="flowchart-${highlightedNodeId}-"]`) + if (nodeElement) { + const svgNode = nodeElement as SVGElement + svgNode.style.opacity = '1' + + // Add thick cyan border with non-scaling stroke + const shape = nodeElement.querySelector('rect, polygon, circle, ellipse, path') + if (shape) { + shape.setAttribute('stroke', '#06b6d4') // cyan-500 + shape.setAttribute('stroke-width', '3') + shape.setAttribute('vector-effect', 'non-scaling-stroke') + } + } + } } } @@ -106,7 +134,7 @@ export function DebugMermaidDiagram({ return () => { mounted = false } - }, [mermaidContent, currentNodeId]) + }, [mermaidContent, currentNodeId, highlightedNodeId]) if (error) { return ( diff --git a/apps/web/src/components/flowchart/ProblemTrace.tsx b/apps/web/src/components/flowchart/ProblemTrace.tsx new file mode 100644 index 00000000..3ff1b2ec --- /dev/null +++ b/apps/web/src/components/flowchart/ProblemTrace.tsx @@ -0,0 +1,392 @@ +'use client' + +import { useState } from 'react' +import type { StateSnapshot, TransformExpression, ProblemValue } from '@/lib/flowcharts/schema' +import { css } from '../../../styled-system/css' +import { vstack, hstack } from '../../../styled-system/patterns' + +interface ProblemTraceProps { + /** Snapshots from simulateWalk - each represents state after visiting a node */ + snapshots: StateSnapshot[] + /** Callback when hovering over a trace step (for mermaid highlighting) */ + onHoverStep?: (nodeId: string | null) => void + /** Whether to show expanded state details by default */ + defaultExpanded?: boolean +} + +/** + * Check if a snapshot has content worth expanding + */ +function hasExpandableContent( + snapshot: StateSnapshot, + prevSnapshot: StateSnapshot | null +): boolean { + // Has transforms + if (snapshot.transforms.length > 0) return true + + // Is first snapshot with working problem + if (snapshot.workingProblem && !prevSnapshot) return true + + // Has working problem that changed from previous + if ( + snapshot.workingProblem && + prevSnapshot?.workingProblem && + snapshot.workingProblem !== prevSnapshot.workingProblem + ) { + return true + } + + return false +} + +/** + * ProblemTrace - Visualizes the step-by-step computation trace of a problem. + * + * Shows: + * - Each node visited during the walk + * - Transforms applied at each node (key: expr → result) + * - Working problem evolution + * - Hover interaction for mermaid diagram highlighting + */ +export function ProblemTrace({ snapshots, onHoverStep, defaultExpanded = false }: ProblemTraceProps) { + const [expandedSteps, setExpandedSteps] = useState>( + defaultExpanded ? new Set(snapshots.map((_, i) => i)) : new Set() + ) + + const toggleStep = (index: number) => { + setExpandedSteps((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + } + + if (snapshots.length === 0) { + return ( +
+ No trace available +
+ ) + } + + return ( +
+ {snapshots.map((snapshot, index) => { + const isExpanded = expandedSteps.has(index) + const isInitial = snapshot.nodeId === 'initial' + const isLast = index === snapshots.length - 1 + const prevSnapshot = index > 0 ? snapshots[index - 1] : null + const isExpandable = hasExpandableContent(snapshot, prevSnapshot) + + return ( +
!isInitial && onHoverStep?.(snapshot.nodeId)} + onMouseLeave={() => onHoverStep?.(null)} + > + {/* Vertical line connecting steps */} + {!isLast && ( +
+ )} + + {/* Step dot */} +
+ + {/* Step content */} +
isExpandable && toggleStep(index)} + > + {/* Step header - compact single line */} +
+ {/* Expand/collapse indicator - only show if expandable */} + {isExpandable ? ( + + ▶ + + ) : ( + + )} + + {/* Node title */} + + {snapshot.nodeTitle || snapshot.nodeId} + + + {/* Transform count badge */} + {snapshot.transforms.length > 0 && ( + + {snapshot.transforms.length} + + )} +
+ + {/* Expanded content */} + {isExpanded && isExpandable && ( +
+ {/* Working problem evolution - only show if it changed from previous */} + {snapshot.workingProblem && + prevSnapshot?.workingProblem && + snapshot.workingProblem !== prevSnapshot.workingProblem && ( +
+
+ + {prevSnapshot.workingProblem} + + + + {snapshot.workingProblem} + +
+
+ )} + + {/* First snapshot - just show initial working problem */} + {snapshot.workingProblem && !prevSnapshot && ( +
+ Problem: + + {snapshot.workingProblem} + +
+ )} + + {/* Transforms */} + {snapshot.transforms.length > 0 && ( +
+ {snapshot.transforms.map((transform, tIndex) => ( + + ))} +
+ )} +
+ )} +
+
+ ) + })} +
+ ) +} + +// ============================================================================= +// Helper Components +// ============================================================================= + +interface TransformDisplayProps { + transform: TransformExpression + result: ProblemValue +} + +/** + * Displays a single transform: key = expr → result + */ +function TransformDisplay({ transform, result }: TransformDisplayProps) { + return ( +
+ {/* Key name */} + + {transform.key} + + + = + + {/* Expression (truncated if long) */} + + {transform.expr} + + + + + {/* Result */} + + {formatResult(result)} + +
+ ) +} + +/** + * Format a ProblemValue for display + */ +function formatResult(value: ProblemValue): string { + if (value === null || value === undefined) return 'null' + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (typeof value === 'object' && 'denom' in value) { + // Mixed number + const { whole, num, denom } = value + if (whole === 0) return `${num}/${denom}` + if (num === 0) return String(whole) + return `${whole} ${num}/${denom}` + } + return String(value) +} diff --git a/apps/web/src/components/flowchart/TestsTab.tsx b/apps/web/src/components/flowchart/TestsTab.tsx index 05df71aa..7df650fd 100644 --- a/apps/web/src/components/flowchart/TestsTab.tsx +++ b/apps/web/src/components/flowchart/TestsTab.tsx @@ -17,6 +17,9 @@ interface TestsTabProps { onUpdateDefinition?: (definition: FlowchartDefinition) => void } +/** Index of the test being edited, or null if not editing */ +type EditingState = { index: number; example: ProblemExample } | null + /** * Tests tab for the flowchart workshop. * Shows test case results and allows adding new tests. @@ -29,6 +32,7 @@ export function TestsTab({ const [localValidationReport, setLocalValidationReport] = useState(null) const [isRunning, setIsRunning] = useState(false) const [showAddTestForm, setShowAddTestForm] = useState(false) + const [editingTest, setEditingTest] = useState(null) // Use external report if provided, otherwise compute locally const validationReport = externalReport ?? localValidationReport @@ -92,6 +96,59 @@ export function TestsTab({ [definition, onUpdateDefinition] ) + // Handle updating an existing test case + const handleUpdateTest = useCallback( + (index: number, example: ProblemExample) => { + if (!definition || !onUpdateDefinition) return + + const existingExamples = definition.problemInput.examples || [] + const updatedExamples = [...existingExamples] + updatedExamples[index] = example + + const updatedDefinition: FlowchartDefinition = { + ...definition, + problemInput: { + ...definition.problemInput, + examples: updatedExamples, + }, + } + onUpdateDefinition(updatedDefinition) + setEditingTest(null) + }, + [definition, onUpdateDefinition] + ) + + // Handle deleting a test case + const handleDeleteTest = useCallback( + (index: number) => { + if (!definition || !onUpdateDefinition) return + + const existingExamples = definition.problemInput.examples || [] + const updatedExamples = existingExamples.filter((_, i) => i !== index) + + const updatedDefinition: FlowchartDefinition = { + ...definition, + problemInput: { + ...definition.problemInput, + examples: updatedExamples, + }, + } + onUpdateDefinition(updatedDefinition) + }, + [definition, onUpdateDefinition] + ) + + // Find the index of a test case in the examples array + const findTestIndex = useCallback( + (example: ProblemExample): number => { + const examples = definition?.problemInput.examples || [] + return examples.findIndex( + (ex) => ex.name === example.name && ex.expectedAnswer === example.expectedAnswer + ) + }, + [definition] + ) + if (!definition) { return (

@@ -184,9 +241,18 @@ export function TestsTab({ > Test Results - {validationReport.results.map((result, index) => ( - - ))} + {validationReport.results.map((result, index) => { + const testIndex = findTestIndex(result.example) + return ( + setEditingTest({ index: testIndex, example: result.example })} + onDelete={() => handleDeleteTest(testIndex)} + /> + ) + })}

)} @@ -299,8 +365,18 @@ export function TestsTab({
)} + {/* Edit Test Form */} + {editingTest && definition && ( + handleUpdateTest(editingTest.index, example)} + onCancel={() => setEditingTest(null)} + /> + )} + {/* Add Test Form */} - {showAddTestForm && definition && ( + {showAddTestForm && definition && !editingTest && ( void + onDelete?: () => void +}) { const [isExpanded, setIsExpanded] = useState(!result.passed) return ( @@ -363,7 +449,7 @@ function TestResultRow({ result }: { result: TestResult }) { })} onClick={() => setIsExpanded(!isExpanded)} > -
+
{result.passed ? '✓' : '✗'}
- - {isExpanded ? '▼' : '▶'} - +
+ {canEdit && ( + <> + + + + )} + + {isExpanded ? '▼' : '▶'} + +
{isExpanded && ( @@ -706,3 +846,279 @@ function AddTestForm({ ) } + +/** + * Form for editing an existing test case + */ +function EditTestForm({ + definition, + example, + onSave, + onCancel, +}: { + definition: FlowchartDefinition + example: ProblemExample + onSave: (example: ProblemExample) => void + onCancel: () => void +}) { + const [name, setName] = useState(example.name) + const [expectedAnswer, setExpectedAnswer] = useState(example.expectedAnswer || '') + const [values, setValues] = useState>({}) + + // Initialize values from example + useEffect(() => { + const initialValues: Record = {} + for (const field of definition.problemInput.fields) { + if (field.type === 'mixed-number') { + const val = example.values[field.name] as { whole?: number; num?: number; denom?: number } | undefined + initialValues[`${field.name}Whole`] = String(val?.whole ?? '') + initialValues[`${field.name}Num`] = String(val?.num ?? '') + initialValues[`${field.name}Denom`] = String(val?.denom ?? '') + } else { + initialValues[field.name] = String(example.values[field.name] ?? '') + } + } + setValues(initialValues) + }, [definition, example]) + + const handleSubmit = useCallback(() => { + if (!name.trim() || !expectedAnswer.trim()) return + + // Convert string values to proper types + const typedValues: Record = {} + for (const field of definition.problemInput.fields) { + if (field.type === 'mixed-number') { + typedValues[field.name] = { + whole: Number(values[`${field.name}Whole`]) || 0, + num: Number(values[`${field.name}Num`]) || 0, + denom: Number(values[`${field.name}Denom`]) || 1, + } + } else if (field.type === 'integer' || field.type === 'number') { + typedValues[field.name] = Number(values[field.name]) || 0 + } else { + typedValues[field.name] = values[field.name] || '' + } + } + + onSave({ + name: name.trim(), + description: example.description, + values: typedValues, + expectedAnswer: expectedAnswer.trim(), + }) + }, [name, expectedAnswer, values, definition, example.description, onSave]) + + return ( +
+

+ Edit Test Case +

+ +
+ {/* Test name */} +
+ + setName(e.target.value)} + className={css({ + width: '100%', + padding: '2', + borderRadius: 'md', + border: '1px solid', + borderColor: { base: 'gray.300', _dark: 'gray.600' }, + backgroundColor: { base: 'white', _dark: 'gray.900' }, + fontSize: 'sm', + })} + /> +
+ + {/* Input values */} +
+ +
+ {definition.problemInput.fields.map((field) => { + if (field.type === 'mixed-number') { + return ( +
+ {field.label || field.name}: +
+ + setValues({ ...values, [`${field.name}Whole`]: e.target.value }) + } + placeholder="Whole" + className={css({ + width: '60px', + padding: '1', + borderRadius: 'sm', + border: '1px solid', + borderColor: { base: 'gray.300', _dark: 'gray.600' }, + fontSize: 'sm', + })} + /> + + setValues({ ...values, [`${field.name}Num`]: e.target.value }) + } + placeholder="Num" + className={css({ + width: '50px', + padding: '1', + borderRadius: 'sm', + border: '1px solid', + borderColor: { base: 'gray.300', _dark: 'gray.600' }, + fontSize: 'sm', + })} + /> + / + + setValues({ ...values, [`${field.name}Denom`]: e.target.value }) + } + placeholder="Denom" + className={css({ + width: '50px', + padding: '1', + borderRadius: 'sm', + border: '1px solid', + borderColor: { base: 'gray.300', _dark: 'gray.600' }, + fontSize: 'sm', + })} + /> +
+
+ ) + } + return ( +
+ {field.label || field.name}: + setValues({ ...values, [field.name]: e.target.value })} + className={css({ + width: '100%', + padding: '1', + borderRadius: 'sm', + border: '1px solid', + borderColor: { base: 'gray.300', _dark: 'gray.600' }, + fontSize: 'sm', + })} + /> +
+ ) + })} +
+
+ + {/* Expected answer */} +
+ + setExpectedAnswer(e.target.value)} + className={css({ + width: '100%', + padding: '2', + borderRadius: 'md', + border: '1px solid', + borderColor: { base: 'gray.300', _dark: 'gray.600' }, + backgroundColor: { base: 'white', _dark: 'gray.900' }, + fontSize: 'sm', + })} + /> +
+ + {/* Buttons */} +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/components/flowchart/WorksheetDebugPanel.tsx b/apps/web/src/components/flowchart/WorksheetDebugPanel.tsx index 7f51fce9..4f465dbe 100644 --- a/apps/web/src/components/flowchart/WorksheetDebugPanel.tsx +++ b/apps/web/src/components/flowchart/WorksheetDebugPanel.tsx @@ -5,7 +5,8 @@ import type { ExecutableFlowchart, ProblemValue, MixedNumberValue } from '@/lib/ import type { GeneratedExample } from '@/lib/flowcharts/loader' import { generateExamplesAsync } from '@/lib/flowcharts/example-generator-client' import { formatProblemDisplay } from '@/lib/flowcharts/formatting' -import { evaluateDisplayAnswer } from '@/lib/flowchart-workshop/test-case-validator' +import { simulateWalk, extractAnswer } from '@/lib/flowcharts/loader' +import { ProblemTrace } from './ProblemTrace' import { css } from '../../../styled-system/css' import { vstack, hstack } from '../../../styled-system/patterns' @@ -14,6 +15,8 @@ interface WorksheetDebugPanelProps { flowchart: ExecutableFlowchart /** Number of problems to generate (default: 10) */ problemCount?: number + /** Callback when hovering over a trace node (for mermaid highlighting) */ + onHoverNode?: (nodeId: string | null) => void } /** Difficulty tier type */ @@ -23,7 +26,7 @@ type DifficultyTier = 'easy' | 'medium' | 'hard' * Debug panel for testing worksheet generation. * Shows generated problems with their computed answers, raw values, and difficulty tiers. */ -export function WorksheetDebugPanel({ flowchart, problemCount = 10 }: WorksheetDebugPanelProps) { +export function WorksheetDebugPanel({ flowchart, problemCount = 10, onHoverNode }: WorksheetDebugPanelProps) { const [examples, setExamples] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) @@ -118,6 +121,28 @@ export function WorksheetDebugPanel({ flowchart, problemCount = 10 }: WorksheetD } } + // Compute simulations and answers for all examples (unified computation path) + const computedExamples = useMemo(() => { + return examples.map((example) => { + try { + const terminalState = simulateWalk(flowchart, example.values) + const { display } = extractAnswer(flowchart, terminalState) + return { + state: terminalState, + answerDisplay: display.text || '?', + error: null, + } + } catch (err) { + console.error('Failed to compute answer for example:', err) + return { + state: null, + answerDisplay: '?', + error: err instanceof Error ? err.message : 'Unknown error', + } + } + }) + }, [examples, flowchart]) + if (isLoading) { return (
@@ -288,10 +313,8 @@ export function WorksheetDebugPanel({ flowchart, problemCount = 10 }: WorksheetD const tierColor = getTierColor(tier) const isExpanded = expandedItems.has(index) const problemDisplay = formatProblemDisplay(flowchart, example.values) - const { answer: answerDisplay } = evaluateDisplayAnswer( - flowchart.definition, - example.values - ) + const computed = computedExamples[index] + const answerDisplay = computed?.answerDisplay ?? '?' return (
+ + {/* Computation Trace */} + {computed?.state?.snapshots && computed.state.snapshots.length > 0 && ( +
+

+ Computation Trace +

+ +
+ )} )} diff --git a/apps/web/src/lib/flowchart-workshop/llm-schemas.ts b/apps/web/src/lib/flowchart-workshop/llm-schemas.ts index 72003001..1efb2384 100644 --- a/apps/web/src/lib/flowchart-workshop/llm-schemas.ts +++ b/apps/web/src/lib/flowchart-workshop/llm-schemas.ts @@ -1319,6 +1319,7 @@ Every example in \`problemInput.examples\` MUST include an \`expectedAnswer\` fi - Include at least one test case for each major path through the flowchart - Cover edge cases where the answer format might change (e.g., improper fractions becoming whole numbers, "5" vs "5/1") - Ensure test cases exercise all branches of conditional logic in \`display.answer\` +- **Handle degenerate computed values**: When intermediate computations can reach boundary conditions (ratio = 1, difference = 0, denominator = 1, etc.), the \`display.answer\` expression must handle these. A multi-part display may need to collapse to fewer parts—test cases should cover inputs that trigger these boundary conditions. **Example**: \`\`\`json @@ -1532,6 +1533,7 @@ Your task is to modify the flowchart according to the teacher's request while: - Missing whole number extraction from improper fractions - Division by zero not handled - String concatenation order issues +- Computed values reaching boundary conditions (e.g., a computed denominator = 1, ratio = 1, or difference = 0) that should collapse the display to a simpler form ` + getCriticalRules() + diff --git a/apps/web/src/lib/flowchart-workshop/test-case-validator.ts b/apps/web/src/lib/flowchart-workshop/test-case-validator.ts index d6e4d0d4..ef9a135f 100644 --- a/apps/web/src/lib/flowchart-workshop/test-case-validator.ts +++ b/apps/web/src/lib/flowchart-workshop/test-case-validator.ts @@ -16,7 +16,7 @@ import type { import { evaluate, type EvalContext } from '../flowcharts/evaluator' import { analyzeFlowchart, type FlowchartPath } from '../flowcharts/path-analysis' import type { ExecutableFlowchart } from '../flowcharts/schema' -import { loadFlowchart } from '../flowcharts/loader' +import { loadFlowchart, simulateWalk, extractAnswer } from '../flowcharts/loader' // ============================================================================= // Types @@ -225,7 +225,7 @@ export function runTestCase(definition: FlowchartDefinition, example: ProblemExa /** * Run a single test case using an ExecutableFlowchart. - * Uses evaluateDisplayAnswer - the canonical answer computation function. + * Uses simulateWalk + extractAnswer for unified answer computation. */ export function runTestCaseWithFlowchart( flowchart: ExecutableFlowchart, @@ -241,30 +241,32 @@ export function runTestCaseWithFlowchart( } } - // Use evaluateDisplayAnswer - handles normalization internally - const { answer, error } = evaluateDisplayAnswer(flowchart.definition, example.values) + // Use simulateWalk + extractAnswer for unified computation + try { + const terminalState = simulateWalk(flowchart, example.values) + const { display: answerDisplay } = extractAnswer(flowchart, terminalState) + const answer = answerDisplay.text || null - if (error) { + // Compare after trimming whitespace + const normalizedActual = answer?.trim() ?? '' + const normalizedExpected = example.expectedAnswer.trim() + const passed = normalizedActual === normalizedExpected + + return { + example, + actualAnswer: answer, + expectedAnswer: example.expectedAnswer, + passed, + } + } catch (err) { return { example, actualAnswer: null, expectedAnswer: example.expectedAnswer, passed: false, - error, + error: err instanceof Error ? err.message : 'Evaluation failed', } } - - // Compare after trimming whitespace - const normalizedActual = answer?.trim() ?? '' - const normalizedExpected = example.expectedAnswer.trim() - const passed = normalizedActual === normalizedExpected - - return { - example, - actualAnswer: answer, - expectedAnswer: example.expectedAnswer, - passed, - } } /** @@ -307,8 +309,8 @@ export function validateTestCases(definition: FlowchartDefinition): ValidationRe /** * Validate test cases with full coverage analysis. - * Uses evaluateDisplayAnswer for validation - the same function - * that worksheet generation uses to compute answers. + * Uses simulateWalk + extractAnswer for validation - the unified + * computation path used by worksheet generation. */ export async function validateTestCasesWithCoverage( definition: FlowchartDefinition, diff --git a/apps/web/src/lib/flowcharts/definitions/fraction-add-sub.flow.json b/apps/web/src/lib/flowcharts/definitions/fraction-add-sub.flow.json index 32f689ad..e0856dd9 100644 --- a/apps/web/src/lib/flowcharts/definitions/fraction-add-sub.flow.json +++ b/apps/web/src/lib/flowcharts/definitions/fraction-add-sub.flow.json @@ -16,7 +16,7 @@ "display": { "problem": "(leftWhole > 0 ? (leftWhole + ' ' + leftNum + '/' + leftDenom) : (leftNum + '/' + leftDenom)) + ' ' + op + ' ' + (rightWhole > 0 ? (rightWhole + ' ' + rightNum + '/' + rightDenom) : (rightNum + '/' + rightDenom))", - "answer": "resultWhole > 0 ? (simplifiedNum > 0 ? (resultWhole + ' ' + simplifiedNum + '/' + simplifiedDenom) : resultWhole) : (simplifiedNum + '/' + simplifiedDenom)" + "answer": "simplifiedDenom == 1 ? (resultWhole + simplifiedNum) : (resultWhole > 0 ? (simplifiedNum > 0 ? (resultWhole + ' ' + simplifiedNum + '/' + simplifiedDenom) : resultWhole) : (simplifiedNum + '/' + simplifiedDenom))" }, "constraints": { diff --git a/apps/web/src/lib/flowcharts/loader.ts b/apps/web/src/lib/flowcharts/loader.ts index f37a4ba8..e4a9d747 100644 --- a/apps/web/src/lib/flowcharts/loader.ts +++ b/apps/web/src/lib/flowcharts/loader.ts @@ -357,21 +357,6 @@ export function applyTransforms( if (!node) return state const transforms = node.definition.transform || [] - if (transforms.length === 0) { - // No transforms, but still add a snapshot for the node - const snapshot: StateSnapshot = { - nodeId, - nodeTitle: node.content?.title || nodeId, - values: { ...state.values }, - transforms: [], - workingProblem: state.workingProblem, - timestamp: Date.now(), - } - return { - ...state, - snapshots: [...state.snapshots, snapshot], - } - } // Apply transforms in order const newValues = { ...state.values } @@ -391,13 +376,42 @@ export function applyTransforms( } } - // Create snapshot after applying transforms + // Check for workingProblemUpdate on this node + let newWorkingProblem = state.workingProblem + let newWorkingProblemHistory = state.workingProblemHistory + const def = node.definition + + let workingProblemUpdate: { result: string; label: string } | undefined + if (def.type === 'checkpoint') { + workingProblemUpdate = (def as CheckpointNode).workingProblemUpdate + } else if (def.type === 'instruction') { + workingProblemUpdate = (def as InstructionNode).workingProblemUpdate + } + + if (workingProblemUpdate) { + try { + const context = createContextFromValues(state.problem, newValues, state.userState) + newWorkingProblem = String(evaluate(workingProblemUpdate.result, context)) + newWorkingProblemHistory = [ + ...state.workingProblemHistory, + { + value: newWorkingProblem, + label: workingProblemUpdate.label, + nodeId, + }, + ] + } catch (error) { + console.error(`Working problem update error at ${nodeId}:`, error) + } + } + + // Create snapshot after applying transforms (with updated working problem) const snapshot: StateSnapshot = { nodeId, nodeTitle: node.content?.title || nodeId, values: { ...newValues }, transforms: appliedTransforms, - workingProblem: state.workingProblem, + workingProblem: newWorkingProblem, timestamp: Date.now(), } @@ -406,6 +420,8 @@ export function applyTransforms( values: newValues, computed: { ...state.computed, ...newValues }, // Keep computed in sync for backwards compat hasError, + workingProblem: newWorkingProblem, + workingProblemHistory: newWorkingProblemHistory, snapshots: [...state.snapshots, snapshot], } } diff --git a/apps/web/src/lib/flowcharts/worksheet-generator.ts b/apps/web/src/lib/flowcharts/worksheet-generator.ts index d67c5745..a4955195 100644 --- a/apps/web/src/lib/flowcharts/worksheet-generator.ts +++ b/apps/web/src/lib/flowcharts/worksheet-generator.ts @@ -12,7 +12,7 @@ import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' import { getFlowchartByIdAsync } from './definitions' -import { loadFlowchart } from './loader' +import { loadFlowchart, simulateWalk, extractAnswer } from './loader' import { generateDiverseExamples, type GeneratedExample, @@ -20,7 +20,6 @@ import { } from './example-generator' import { formatProblemDisplay } from './formatting' import type { ExecutableFlowchart, ProblemValue } from './schema' -import { evaluateDisplayAnswer } from '../flowchart-workshop/test-case-validator' // ============================================================================= // Types @@ -172,13 +171,19 @@ function exampleToProblem( ): WorksheetProblem { const display = formatProblemDisplay(flowchart, example.values) - // Use evaluateDisplayAnswer to compute the answer using the flowchart's display.answer - const { answer: computedAnswer } = evaluateDisplayAnswer(flowchart.definition, example.values) - const answer = computedAnswer ?? '?' - - // Convert plain text answer to Typst format - // For fractions (e.g., "3/4" or "2 1/2"), convert to Typst math mode - const typstAnswer = convertToTypstAnswer(answer) + // Use simulateWalk + extractAnswer for unified answer computation + let answer = '?' + let typstAnswer = '?' + try { + const terminalState = simulateWalk(flowchart, example.values) + const { display: answerDisplay } = extractAnswer(flowchart, terminalState) + answer = answerDisplay.text || '?' + // Use typst template if provided, otherwise convert from text + typstAnswer = answerDisplay.typst || convertToTypstAnswer(answer) + } catch (err) { + console.error('Failed to compute answer via simulateWalk:', err) + typstAnswer = convertToTypstAnswer(answer) + } return { values: example.values,