From 524db2fc41debb95962e1d584d5ea33cc221079d Mon Sep 17 00:00:00 2001 From: sfw Date: Fri, 20 Mar 2026 02:04:56 -0600 Subject: [PATCH] Add interactive practice blocks --- frontend/src/api.test.ts | 53 +++++++ frontend/src/api.ts | 33 +++++ .../components/content/ContentBlock.test.tsx | 49 ++++++- .../src/components/content/ContentBlock.tsx | 60 +++++--- .../content/InteractivePracticeBlock.tsx | 128 +++++++++++++++++ .../components/content/StreamingContent.tsx | 9 +- frontend/src/hooks/useGenerationWorkspace.ts | 9 +- frontend/src/lib/forms.test.ts | 57 ++++++++ frontend/src/lib/forms.ts | 4 + frontend/src/types.ts | 26 ++++ .../src/views/learner/ContinueLearning.tsx | 100 +++++++++++-- src/dibble/api/learner_routes.py | 5 + src/dibble/models/generation.py | 43 +++++- src/dibble/models/observations.py | 10 ++ src/dibble/services/content_moderation.py | 13 +- src/dibble/services/content_provider.py | 48 +++++-- .../services/generated_block_normalizer.py | 133 ++++++++++++++++++ src/dibble/services/generation_engine.py | 9 +- src/dibble/services/llm_prompting.py | 50 ++++++- src/dibble/services/prompt_manager.py | 10 +- src/dibble/services/streaming.py | 10 ++ src/dibble/services/validation/rules.py | 36 +++-- tests/test_api_content.py | 11 +- tests/test_api_generation_modes.py | 8 +- tests/test_generation_engine.py | 127 +++++++++++++++++ tests/test_provider.py | 2 + 26 files changed, 970 insertions(+), 73 deletions(-) create mode 100644 frontend/src/components/content/InteractivePracticeBlock.tsx create mode 100644 src/dibble/services/generated_block_normalizer.py diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 89c64f0..650b710 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -7,6 +7,7 @@ import { getTeacherSection, getTeacherSections, getLearnerWorkspace, + recordLearnerObservation, recordTeacherInterventionAction, streamGeneration, } from './api' @@ -141,6 +142,58 @@ describe('api contract helpers', () => { ) }) + it('posts learner observations for generated interactions', async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: 'ok' })) + vi.stubGlobal('fetch', fetchMock) + + await recordLearnerObservation(defaultConfig, demoProfileSummary.student_id, { + response_time_ms: 4200, + task_type: 'practice', + support_level: 'medium', + learning_session_id: 'session-1', + generation_id: demoGeneration.generation_id, + observed_content_type: 'practice_problem', + target_kc_ids: ['KC-1'], + target_lo_ids: ['LO-1'], + interaction_events: [ + { + event_type: 'multiple_choice_selected', + block_id: 'block-1', + selected_option_id: 'B', + correct: true, + }, + ], + }) + + expect(fetchMock).toHaveBeenCalledWith( + `${defaultConfig.baseUrl}/api/learners/${demoProfileSummary.student_id}/observations`, + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + response_time_ms: 4200, + task_type: 'practice', + support_level: 'medium', + learning_session_id: 'session-1', + generation_id: demoGeneration.generation_id, + observed_content_type: 'practice_problem', + target_kc_ids: ['KC-1'], + target_lo_ids: ['LO-1'], + interaction_events: [ + { + event_type: 'multiple_choice_selected', + block_id: 'block-1', + selected_option_id: 'B', + correct: true, + }, + ], + }), + }), + ) + }) + it('loads learner progression and teacher classroom contracts', async () => { fetchMock .mockResolvedValueOnce(jsonResponse(demoCurriculumProgression)) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 003652c..08211ea 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -141,6 +141,39 @@ export function getLearnerWorkspace(config: FrontendConfig, studentId: string) { }) } +export function recordLearnerObservation( + config: FrontendConfig, + studentId: string, + payload: { + response_time_ms: number + hints_used?: number + error_count?: number + completed?: boolean + confidence?: number + task_type: 'practice' | 'assessment' | 'worked_example' | 'explanation' | 'remediation' | 'generic' + support_level: 'low' | 'medium' | 'high' + learning_session_id?: string | null + generation_id?: string | null + observed_content_type?: string | null + target_kc_ids: string[] + target_lo_ids: string[] + interaction_events?: Array<{ + event_type: string + block_id: string + selected_option_id?: string | null + correct?: boolean | null + response_text?: string | null + }> + response_text?: string | null + }, +) { + return requestJson(config, `/api/learners/${studentId}/observations`, { + method: 'POST', + headers: buildHeaders(config), + body: JSON.stringify(payload), + }) +} + export function getLearnerProgression(config: FrontendConfig, studentId: string) { return requestJson( config, diff --git a/frontend/src/components/content/ContentBlock.test.tsx b/frontend/src/components/content/ContentBlock.test.tsx index bfb370c..d0526c5 100644 --- a/frontend/src/components/content/ContentBlock.test.tsx +++ b/frontend/src/components/content/ContentBlock.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' import { ContentBlock } from './ContentBlock' import type { GeneratedBlock } from '../../types' @@ -79,4 +80,50 @@ describe('ContentBlock', () => { expect(screen.getByText('Test body content.')).toBeInTheDocument() expect(screen.queryByRole('heading')).not.toBeInTheDocument() }) + + it('renders interactive practice blocks and submits learner input', async () => { + const user = userEvent.setup() + const onPracticeSubmit = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: /option b/i })) + await user.type(screen.getByPlaceholderText('Explain your thinking.'), 'The decimal points line up.') + await user.click(screen.getByRole('button', { name: /submit and continue/i })) + + expect(onPracticeSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + blockId: 'block-1', + selectedOptionId: 'B', + isCorrect: true, + responseText: 'The decimal points line up.', + }), + ) + }) }) diff --git a/frontend/src/components/content/ContentBlock.tsx b/frontend/src/components/content/ContentBlock.tsx index 666e44e..21d389a 100644 --- a/frontend/src/components/content/ContentBlock.tsx +++ b/frontend/src/components/content/ContentBlock.tsx @@ -1,5 +1,9 @@ import { BookOpen, Code, Image, Lightbulb, PenTool, HelpCircle } from 'lucide-react' import type { GeneratedBlock } from '../../types' +import { + InteractivePracticeBlock, + type PracticeInteractionSubmission, +} from './InteractivePracticeBlock' /** * Renders a GeneratedBlock according to its `kind`. @@ -16,16 +20,29 @@ import type { GeneratedBlock } from '../../types' * * Falls back to prose rendering for unknown kinds. */ -export function ContentBlock({ block }: { block: GeneratedBlock }) { +export function ContentBlock({ + block, + disabled = false, + onPracticeSubmit, +}: { + block: GeneratedBlock + disabled?: boolean + onPracticeSubmit?: (submission: PracticeInteractionSubmission) => void +}) { const renderer = blockRenderers[block.kind] ?? blockRenderers['default'] - return renderer(block) + return renderer(block, { disabled, onPracticeSubmit }) } // --------------------------------------------------------------------------- // Block renderers by kind // --------------------------------------------------------------------------- -const blockRenderers: Record React.JSX.Element> = { +type RendererContext = { + disabled: boolean + onPracticeSubmit?: (submission: PracticeInteractionSubmission) => void +} + +const blockRenderers: Record React.JSX.Element> = { // Code blocks code_example: (block) => (
@@ -63,7 +80,7 @@ const blockRenderers: Record React.JSX.Elemen
), - diagram: (block) => blockRenderers['visual_representation'](block), + diagram: (block, context) => blockRenderers['visual_representation'](block, context), // Worked examples — step-by-step with distinct styling worked_example: (block) => ( @@ -79,17 +96,28 @@ const blockRenderers: Record React.JSX.Elemen ), // Practice problems — interactive feel - practice_problem: (block) => ( -
- {block.title && ( -
- -

{block.title}

-
- )} -
{renderParagraphs(block.body)}
-
- ), + practice_problem: (block, context) => { + if (block.interaction?.type === 'multiple_choice' && context.onPracticeSubmit) { + return ( + + ) + } + return ( +
+ {block.title && ( +
+ +

{block.title}

+
+ )} +
{renderParagraphs(block.body)}
+
+ ) + }, // Scaffolded steps scaffolded_steps: (block) => ( @@ -132,7 +160,7 @@ const blockRenderers: Record React.JSX.Elemen ), - exposition: (block) => blockRenderers['conceptual_explanation'](block), + exposition: (block, context) => blockRenderers['conceptual_explanation'](block, context), // Default / unknown kind — clean prose default: (block) => ( diff --git a/frontend/src/components/content/InteractivePracticeBlock.tsx b/frontend/src/components/content/InteractivePracticeBlock.tsx new file mode 100644 index 0000000..ffaefc2 --- /dev/null +++ b/frontend/src/components/content/InteractivePracticeBlock.tsx @@ -0,0 +1,128 @@ +import { useRef, useState } from 'react' +import { CheckCircle2, Circle, ArrowRight } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import type { GeneratedBlock } from '../../types' + +export interface PracticeInteractionSubmission { + blockId: string + selectedOptionId: string + isCorrect: boolean + responseText: string + responseTimeMs: number + hintsUsed: number +} + +export function InteractivePracticeBlock({ + block, + disabled = false, + onSubmit, +}: { + block: GeneratedBlock + disabled?: boolean + onSubmit: (submission: PracticeInteractionSubmission) => void +}) { + const interaction = block.interaction + const startedAt = useRef(null) + const [selectedOptionId, setSelectedOptionId] = useState(null) + const [responseText, setResponseText] = useState('') + + if (interaction?.type !== 'multiple_choice') { + return null + } + + const reveal = interaction.reveal + const selectedOption = selectedOptionId + ? interaction.options.find((option) => option.option_id === selectedOptionId) ?? null + : null + const canSubmit = selectedOptionId !== null && (reveal == null || responseText.trim().length > 0) + + return ( +
+ {block.title &&

{block.title}

} + {block.body &&

{block.body}

} + +
+

{interaction.prompt}

+ {interaction.options.map((option) => { + const selected = option.option_id === selectedOptionId + return ( + + ) + })} +
+ + {selectedOption && reveal && ( +
+

Verify your reasoning

+

{reveal.prompt}

+ {reveal.support && ( +

{reveal.support}

+ )} +