Add difficulty tier selection with dynamic grid dimensions
- Add Easy/Medium/Hard tier filter tabs to flowchart example picker - Create inferGridDimensionsFromExamples() to dynamically determine grid axes based on which dimensions vary within filtered examples - Grid adapts to show the most meaningful dimensions for each tier - Difficulty scoring uses existing path complexity (decisions + checkpoints) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
72362b6a3e
commit
c6a8d5d1d8
|
|
@ -13,6 +13,7 @@ import {
|
|||
generateDiverseExamples,
|
||||
analyzeFlowchart,
|
||||
inferGridDimensions,
|
||||
inferGridDimensionsFromExamples,
|
||||
calculatePathComplexity,
|
||||
type GeneratedExample,
|
||||
type GenerationConstraints,
|
||||
|
|
@ -25,6 +26,9 @@ import { css } from '../../../styled-system/css'
|
|||
import { vstack, hstack } from '../../../styled-system/patterns'
|
||||
import { MathDisplay } from './MathDisplay'
|
||||
|
||||
/** Difficulty tier for filtering examples */
|
||||
type DifficultyTier = 'easy' | 'medium' | 'hard' | 'all'
|
||||
|
||||
interface FlowchartProblemInputProps {
|
||||
schema: ProblemInputSchema
|
||||
onSubmit: (values: Record<string, ProblemValue>) => void
|
||||
|
|
@ -58,6 +62,8 @@ export function FlowchartProblemInput({
|
|||
example: GeneratedExample
|
||||
buttonRect: DOMRect
|
||||
} | null>(null)
|
||||
// Selected difficulty tier for filtering examples
|
||||
const [selectedTier, setSelectedTier] = useState<DifficultyTier>('all')
|
||||
// Ref to the container for positioning the popover
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
|
@ -79,8 +85,8 @@ export function FlowchartProblemInput({
|
|||
}
|
||||
}, [flowchart])
|
||||
|
||||
// Infer grid dimensions from flowchart decision structure
|
||||
const gridDimensions = useMemo(() => {
|
||||
// Infer grid dimensions from flowchart decision structure (for "All" view)
|
||||
const baseGridDimensions = useMemo(() => {
|
||||
if (!flowchart || !analysis) return null
|
||||
try {
|
||||
return inferGridDimensions(flowchart, analysis.paths)
|
||||
|
|
@ -149,6 +155,54 @@ export function FlowchartProblemInput({
|
|||
return 3
|
||||
}
|
||||
|
||||
// Get difficulty tier for an example (matches the tier selection)
|
||||
const getDifficultyTier = (example: GeneratedExample): 'easy' | 'medium' | 'hard' => {
|
||||
const level = getDifficultyLevel(example)
|
||||
switch (level) {
|
||||
case 1:
|
||||
return 'easy'
|
||||
case 2:
|
||||
return 'medium'
|
||||
case 3:
|
||||
return 'hard'
|
||||
}
|
||||
}
|
||||
|
||||
// Filter examples by selected tier
|
||||
const filteredExamples = useMemo(() => {
|
||||
if (selectedTier === 'all') return generatedExamples
|
||||
return generatedExamples.filter((ex) => getDifficultyTier(ex) === selectedTier)
|
||||
}, [generatedExamples, selectedTier, difficultyRange])
|
||||
|
||||
// Count examples by tier for display
|
||||
const tierCounts = useMemo(() => {
|
||||
const counts = { easy: 0, medium: 0, hard: 0 }
|
||||
for (const ex of generatedExamples) {
|
||||
counts[getDifficultyTier(ex)]++
|
||||
}
|
||||
return counts
|
||||
}, [generatedExamples, difficultyRange])
|
||||
|
||||
// Dynamic grid dimensions based on filtered examples
|
||||
// When a tier is selected, use the dimensions that actually vary within that tier
|
||||
const gridDimensions = useMemo(() => {
|
||||
if (!flowchart) return null
|
||||
|
||||
// For "All" view, use the base grid dimensions (from all paths)
|
||||
if (selectedTier === 'all') {
|
||||
return baseGridDimensions
|
||||
}
|
||||
|
||||
// For tier-filtered views, dynamically infer dimensions from filtered examples
|
||||
try {
|
||||
return inferGridDimensionsFromExamples(flowchart, filteredExamples)
|
||||
} catch (e) {
|
||||
console.error('Error inferring dynamic grid dimensions:', e)
|
||||
// Fall back to base dimensions if dynamic inference fails
|
||||
return baseGridDimensions
|
||||
}
|
||||
}, [flowchart, selectedTier, filteredExamples, baseGridDimensions])
|
||||
|
||||
// Get border color based on difficulty
|
||||
const getDifficultyBorderColor = (level: 1 | 2 | 3) => {
|
||||
switch (level) {
|
||||
|
|
@ -462,8 +516,111 @@ export function FlowchartProblemInput({
|
|||
</h2>
|
||||
)}
|
||||
|
||||
{/* Examples Section */}
|
||||
{/* Difficulty Tier Selection */}
|
||||
{generatedExamples.length > 0 && (
|
||||
<div
|
||||
data-testid="tier-selection"
|
||||
className={hstack({
|
||||
gap: '1',
|
||||
justifyContent: 'center',
|
||||
padding: '1',
|
||||
backgroundColor: { base: 'gray.100', _dark: 'gray.700' },
|
||||
borderRadius: 'lg',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
data-tier="all"
|
||||
data-selected={selectedTier === 'all'}
|
||||
onClick={() => setSelectedTier('all')}
|
||||
className={css({
|
||||
padding: '1.5 3',
|
||||
fontSize: 'sm',
|
||||
fontWeight: selectedTier === 'all' ? 'bold' : 'medium',
|
||||
borderRadius: 'md',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
backgroundColor: selectedTier === 'all' ? { base: 'white', _dark: 'gray.600' } : 'transparent',
|
||||
color: selectedTier === 'all' ? { base: 'gray.900', _dark: 'white' } : { base: 'gray.500', _dark: 'gray.400' },
|
||||
boxShadow: selectedTier === 'all' ? 'sm' : 'none',
|
||||
_hover: {
|
||||
backgroundColor: selectedTier === 'all' ? { base: 'white', _dark: 'gray.600' } : { base: 'gray.200', _dark: 'gray.600' },
|
||||
},
|
||||
})}
|
||||
>
|
||||
All ({generatedExamples.length})
|
||||
</button>
|
||||
<button
|
||||
data-tier="easy"
|
||||
data-selected={selectedTier === 'easy'}
|
||||
onClick={() => setSelectedTier('easy')}
|
||||
className={css({
|
||||
padding: '1.5 3',
|
||||
fontSize: 'sm',
|
||||
fontWeight: selectedTier === 'easy' ? 'bold' : 'medium',
|
||||
borderRadius: 'md',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
backgroundColor: selectedTier === 'easy' ? { base: 'green.100', _dark: 'green.800' } : 'transparent',
|
||||
color: selectedTier === 'easy' ? { base: 'green.700', _dark: 'green.200' } : { base: 'gray.500', _dark: 'gray.400' },
|
||||
boxShadow: selectedTier === 'easy' ? 'sm' : 'none',
|
||||
_hover: {
|
||||
backgroundColor: selectedTier === 'easy' ? { base: 'green.100', _dark: 'green.800' } : { base: 'gray.200', _dark: 'gray.600' },
|
||||
},
|
||||
})}
|
||||
>
|
||||
Easy ({tierCounts.easy})
|
||||
</button>
|
||||
<button
|
||||
data-tier="medium"
|
||||
data-selected={selectedTier === 'medium'}
|
||||
onClick={() => setSelectedTier('medium')}
|
||||
className={css({
|
||||
padding: '1.5 3',
|
||||
fontSize: 'sm',
|
||||
fontWeight: selectedTier === 'medium' ? 'bold' : 'medium',
|
||||
borderRadius: 'md',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
backgroundColor: selectedTier === 'medium' ? { base: 'orange.100', _dark: 'orange.800' } : 'transparent',
|
||||
color: selectedTier === 'medium' ? { base: 'orange.700', _dark: 'orange.200' } : { base: 'gray.500', _dark: 'gray.400' },
|
||||
boxShadow: selectedTier === 'medium' ? 'sm' : 'none',
|
||||
_hover: {
|
||||
backgroundColor: selectedTier === 'medium' ? { base: 'orange.100', _dark: 'orange.800' } : { base: 'gray.200', _dark: 'gray.600' },
|
||||
},
|
||||
})}
|
||||
>
|
||||
Medium ({tierCounts.medium})
|
||||
</button>
|
||||
<button
|
||||
data-tier="hard"
|
||||
data-selected={selectedTier === 'hard'}
|
||||
onClick={() => setSelectedTier('hard')}
|
||||
className={css({
|
||||
padding: '1.5 3',
|
||||
fontSize: 'sm',
|
||||
fontWeight: selectedTier === 'hard' ? 'bold' : 'medium',
|
||||
borderRadius: 'md',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
backgroundColor: selectedTier === 'hard' ? { base: 'red.100', _dark: 'red.800' } : 'transparent',
|
||||
color: selectedTier === 'hard' ? { base: 'red.700', _dark: 'red.200' } : { base: 'gray.500', _dark: 'gray.400' },
|
||||
boxShadow: selectedTier === 'hard' ? 'sm' : 'none',
|
||||
_hover: {
|
||||
backgroundColor: selectedTier === 'hard' ? { base: 'red.100', _dark: 'red.800' } : { base: 'gray.200', _dark: 'gray.600' },
|
||||
},
|
||||
})}
|
||||
>
|
||||
Hard ({tierCounts.hard})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples Section */}
|
||||
{filteredExamples.length > 0 ? (
|
||||
<div data-testid="examples-section" className={vstack({ gap: '3', alignItems: 'stretch' })}>
|
||||
{/* Example grid - 1D or 2D layout based on grid dimensions */}
|
||||
{gridDimensions && gridDimensions.cols.length > 0 ? (
|
||||
|
|
@ -529,7 +686,7 @@ export function FlowchartProblemInput({
|
|||
// Cells for this row
|
||||
...gridDimensions.cols.map((col, colIdx) => {
|
||||
// Find example that matches this cell
|
||||
const example = generatedExamples.find((ex) => {
|
||||
const example = filteredExamples.find((ex) => {
|
||||
const cell = gridDimensions.cellMap.get(ex.pathDescriptor)
|
||||
return cell && cell[0] === rowIdx && cell[1] === colIdx
|
||||
})
|
||||
|
|
@ -657,7 +814,7 @@ export function FlowchartProblemInput({
|
|||
>
|
||||
{gridDimensions.rows.map((group, groupIdx) => {
|
||||
// Find example(s) for this group
|
||||
const groupExamples = generatedExamples.filter((ex) => {
|
||||
const groupExamples = filteredExamples.filter((ex) => {
|
||||
const cell = gridDimensions.cellMap.get(ex.pathDescriptor)
|
||||
return cell && cell[0] === groupIdx
|
||||
})
|
||||
|
|
@ -781,7 +938,7 @@ export function FlowchartProblemInput({
|
|||
/* Fallback: flat 3-column grid when no grid dimensions */
|
||||
<div
|
||||
data-grid-type="flat"
|
||||
data-count={generatedExamples.length}
|
||||
data-count={filteredExamples.length}
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
|
|
@ -789,7 +946,7 @@ export function FlowchartProblemInput({
|
|||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{generatedExamples.map((example, idx) => (
|
||||
{filteredExamples.map((example, idx) => (
|
||||
<button
|
||||
key={`${example.pathSignature}-${idx}`}
|
||||
data-testid={`example-button-${idx}`}
|
||||
|
|
@ -891,7 +1048,20 @@ export function FlowchartProblemInput({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : generatedExamples.length > 0 && selectedTier !== 'all' ? (
|
||||
/* No examples in selected tier */
|
||||
<div
|
||||
data-testid="no-tier-examples"
|
||||
className={css({
|
||||
padding: '4',
|
||||
textAlign: 'center',
|
||||
color: { base: 'gray.500', _dark: 'gray.400' },
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
No {selectedTier} examples available. Try selecting a different difficulty level.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Edit Popover - shows when editing an example */}
|
||||
{editingExample && containerRef.current && (
|
||||
|
|
|
|||
|
|
@ -1400,6 +1400,220 @@ function inferGridFromDescriptors(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer grid dimensions dynamically from a set of examples.
|
||||
* Unlike inferGridDimensions (which uses all possible paths), this function
|
||||
* analyzes which dimensions actually VARY within the given examples and
|
||||
* uses the top 2 varying dimensions as grid axes.
|
||||
*
|
||||
* This is useful when filtering by difficulty tier - the grid adapts to show
|
||||
* the dimensions that are most meaningful for that tier.
|
||||
*/
|
||||
export function inferGridDimensionsFromExamples(
|
||||
flowchart: ExecutableFlowchart,
|
||||
examples: GeneratedExample[]
|
||||
): GridDimensions | null {
|
||||
if (examples.length === 0) return null
|
||||
|
||||
// Step 1: Extract decision choices from each example's pathSignature
|
||||
// pathSignature is "NODE1→NODE2→NODE3→..." - we trace through to find decisions
|
||||
const exampleDecisions: Array<Map<string, { pathLabel: string; gridLabel?: string }>> = []
|
||||
|
||||
for (const example of examples) {
|
||||
const nodeIds = example.pathSignature.split('→')
|
||||
const decisions = new Map<string, { pathLabel: string; gridLabel?: string }>()
|
||||
|
||||
for (let i = 0; i < nodeIds.length - 1; i++) {
|
||||
const nodeId = nodeIds[i]
|
||||
const nextNodeId = nodeIds[i + 1]
|
||||
const node = flowchart.nodes[nodeId]
|
||||
|
||||
if (node?.definition.type === 'decision') {
|
||||
const decision = node.definition as DecisionNode
|
||||
const option = decision.options.find(o => o.next === nextNodeId)
|
||||
if (option?.pathLabel) {
|
||||
decisions.set(nodeId, {
|
||||
pathLabel: option.pathLabel,
|
||||
gridLabel: option.gridLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exampleDecisions.push(decisions)
|
||||
}
|
||||
|
||||
// Step 2: Count unique values per decision node
|
||||
const decisionVariation = new Map<string, {
|
||||
uniqueValues: Set<string>
|
||||
pathLabels: Map<string, string> // value -> pathLabel
|
||||
gridLabels: Map<string, string | undefined> // value -> gridLabel
|
||||
}>()
|
||||
|
||||
for (const decisions of exampleDecisions) {
|
||||
for (const [nodeId, { pathLabel, gridLabel }] of decisions) {
|
||||
let info = decisionVariation.get(nodeId)
|
||||
if (!info) {
|
||||
info = {
|
||||
uniqueValues: new Set(),
|
||||
pathLabels: new Map(),
|
||||
gridLabels: new Map(),
|
||||
}
|
||||
decisionVariation.set(nodeId, info)
|
||||
}
|
||||
info.uniqueValues.add(pathLabel)
|
||||
info.pathLabels.set(pathLabel, pathLabel)
|
||||
info.gridLabels.set(pathLabel, gridLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Rank decisions by variation (number of unique values)
|
||||
const rankedDecisions = [...decisionVariation.entries()]
|
||||
.filter(([, info]) => info.uniqueValues.size >= 2) // Only decisions with variation
|
||||
.sort((a, b) => {
|
||||
// Primary: more unique values = more important
|
||||
const diff = b[1].uniqueValues.size - a[1].uniqueValues.size
|
||||
if (diff !== 0) return diff
|
||||
// Secondary: more examples that hit this decision
|
||||
const aCount = exampleDecisions.filter(d => d.has(a[0])).length
|
||||
const bCount = exampleDecisions.filter(d => d.has(b[0])).length
|
||||
return bCount - aCount
|
||||
})
|
||||
|
||||
if (rankedDecisions.length === 0) {
|
||||
// No varying dimensions - fall back to single cell or descriptor-based
|
||||
return inferGridFromDescriptorsFromExamples(examples)
|
||||
}
|
||||
|
||||
// Step 4: Build grid from top 1 or 2 dimensions
|
||||
const dim1NodeId = rankedDecisions[0][0]
|
||||
const dim1Info = rankedDecisions[0][1]
|
||||
|
||||
if (rankedDecisions.length === 1) {
|
||||
// 1D grid
|
||||
const rows: string[] = []
|
||||
const rowKeys: string[] = []
|
||||
|
||||
for (const pathLabel of dim1Info.uniqueValues) {
|
||||
const gridLabel = dim1Info.gridLabels.get(pathLabel)
|
||||
rows.push(gridLabel !== undefined ? gridLabel : pathLabel)
|
||||
rowKeys.push(pathLabel)
|
||||
}
|
||||
|
||||
// Build cell map
|
||||
const cellMap = new Map<string, [number, number]>()
|
||||
for (const example of examples) {
|
||||
const rowIdx = rowKeys.findIndex(k =>
|
||||
example.pathDescriptor === k ||
|
||||
example.pathDescriptor.startsWith(k + ' ') ||
|
||||
example.pathDescriptor.includes(' ' + k + ' ') ||
|
||||
example.pathDescriptor.endsWith(' ' + k)
|
||||
)
|
||||
if (rowIdx !== -1) {
|
||||
cellMap.set(example.pathDescriptor, [rowIdx, 0])
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, cols: [], rowKeys, colKeys: [], cellMap }
|
||||
}
|
||||
|
||||
// 2D grid
|
||||
const dim2NodeId = rankedDecisions[1][0]
|
||||
const dim2Info = rankedDecisions[1][1]
|
||||
|
||||
// Determine which dimension appears first in paths (use as rows)
|
||||
let dim1First = true
|
||||
for (const decisions of exampleDecisions) {
|
||||
const keys = [...decisions.keys()]
|
||||
const idx1 = keys.indexOf(dim1NodeId)
|
||||
const idx2 = keys.indexOf(dim2NodeId)
|
||||
if (idx1 !== -1 && idx2 !== -1) {
|
||||
dim1First = idx1 < idx2
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const rowInfo = dim1First ? dim1Info : dim2Info
|
||||
const colInfo = dim1First ? dim2Info : dim1Info
|
||||
|
||||
const rows: string[] = []
|
||||
const rowKeys: string[] = []
|
||||
const cols: string[] = []
|
||||
const colKeys: string[] = []
|
||||
|
||||
for (const pathLabel of rowInfo.uniqueValues) {
|
||||
const gridLabel = rowInfo.gridLabels.get(pathLabel)
|
||||
rows.push(gridLabel !== undefined ? gridLabel : pathLabel)
|
||||
rowKeys.push(pathLabel)
|
||||
}
|
||||
|
||||
for (const pathLabel of colInfo.uniqueValues) {
|
||||
const gridLabel = colInfo.gridLabels.get(pathLabel)
|
||||
cols.push(gridLabel !== undefined ? gridLabel : pathLabel)
|
||||
colKeys.push(pathLabel)
|
||||
}
|
||||
|
||||
// Build cell map
|
||||
const cellMap = new Map<string, [number, number]>()
|
||||
for (const example of examples) {
|
||||
// Find row - which rowKey appears in the descriptor?
|
||||
const rowIdx = rowKeys.findIndex(k =>
|
||||
example.pathDescriptor === k ||
|
||||
example.pathDescriptor.startsWith(k + ' ') ||
|
||||
example.pathDescriptor.includes(' ' + k + ' ') ||
|
||||
example.pathDescriptor.endsWith(' ' + k)
|
||||
)
|
||||
|
||||
// Find col - which colKey appears in the descriptor?
|
||||
const colIdx = colKeys.findIndex(k =>
|
||||
example.pathDescriptor === k ||
|
||||
example.pathDescriptor.startsWith(k + ' ') ||
|
||||
example.pathDescriptor.includes(' ' + k + ' ') ||
|
||||
example.pathDescriptor.endsWith(' ' + k)
|
||||
)
|
||||
|
||||
if (rowIdx !== -1 && colIdx !== -1) {
|
||||
cellMap.set(example.pathDescriptor, [rowIdx, colIdx])
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, cols, rowKeys, colKeys, cellMap }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: infer grid from pathDescriptor strings when no varying decisions found
|
||||
*/
|
||||
function inferGridFromDescriptorsFromExamples(
|
||||
examples: GeneratedExample[]
|
||||
): GridDimensions | null {
|
||||
const descriptors = [...new Set(examples.map(ex => ex.pathDescriptor))]
|
||||
|
||||
if (descriptors.length < 2) {
|
||||
// Single cell - all examples in one group
|
||||
const cellMap = new Map<string, [number, number]>()
|
||||
for (const ex of examples) {
|
||||
cellMap.set(ex.pathDescriptor, [0, 0])
|
||||
}
|
||||
return {
|
||||
rows: [descriptors[0] || 'All'],
|
||||
cols: [],
|
||||
rowKeys: [descriptors[0] || 'All'],
|
||||
colKeys: [],
|
||||
cellMap
|
||||
}
|
||||
}
|
||||
|
||||
// Simple 1D grid with each descriptor as a row
|
||||
const rows = descriptors
|
||||
const rowKeys = descriptors
|
||||
const cellMap = new Map<string, [number, number]>()
|
||||
for (let i = 0; i < descriptors.length; i++) {
|
||||
cellMap.set(descriptors[i], [i, 0])
|
||||
}
|
||||
|
||||
return { rows, cols: [], rowKeys, colKeys: [], cellMap }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constraint-Guided Generation System
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue