Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions frontend/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getTeacherSection,
getTeacherSections,
getLearnerWorkspace,
recordLearnerObservation,
recordTeacherInterventionAction,
streamGeneration,
} from './api'
Expand Down Expand Up @@ -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))
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>(config, `/api/learners/${studentId}/observations`, {
method: 'POST',
headers: buildHeaders(config),
body: JSON.stringify(payload),
})
}

export function getLearnerProgression(config: FrontendConfig, studentId: string) {
return requestJson<LearnerCurriculumProgressionSummary>(
config,
Expand Down
49 changes: 48 additions & 1 deletion frontend/src/components/content/ContentBlock.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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(
<ContentBlock
block={makeBlock({
block_id: 'block-1',
kind: 'practice_problem',
title: 'Choose the Setup',
body: 'Select the best setup.',
interaction: {
type: 'multiple_choice',
prompt: 'Which setup preserves place value?',
options: [
{ option_id: 'A', label: 'Option A', body: 'Right-align every digit.' },
{ option_id: 'B', label: 'Option B', body: 'Align the decimal points.' },
],
correct_option_id: 'B',
reveal: {
trigger: 'after_selection',
prompt: 'Explain why your choice is correct.',
support: 'Line up tenths with tenths.',
placeholder: 'Explain your thinking.',
},
allow_retry: false,
},
})}
onPracticeSubmit={onPracticeSubmit}
/>,
)

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.',
}),
)
})
})
60 changes: 44 additions & 16 deletions frontend/src/components/content/ContentBlock.tsx
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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<string, (block: GeneratedBlock) => React.JSX.Element> = {
type RendererContext = {
disabled: boolean
onPracticeSubmit?: (submission: PracticeInteractionSubmission) => void
}

const blockRenderers: Record<string, (block: GeneratedBlock, context: RendererContext) => React.JSX.Element> = {
// Code blocks
code_example: (block) => (
<article className="rounded-xl border bg-slate-900 p-5 shadow-sm">
Expand Down Expand Up @@ -63,7 +80,7 @@ const blockRenderers: Record<string, (block: GeneratedBlock) => React.JSX.Elemen
</article>
),

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) => (
Expand All @@ -79,17 +96,28 @@ const blockRenderers: Record<string, (block: GeneratedBlock) => React.JSX.Elemen
),

// Practice problems — interactive feel
practice_problem: (block) => (
<article className="rounded-xl border-l-4 border-l-emerald-400 bg-white p-6 shadow-sm">
{block.title && (
<div className="mb-3 flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-emerald-500" />
<h2 className="text-lg font-semibold">{block.title}</h2>
</div>
)}
<div className="text-base leading-relaxed">{renderParagraphs(block.body)}</div>
</article>
),
practice_problem: (block, context) => {
if (block.interaction?.type === 'multiple_choice' && context.onPracticeSubmit) {
return (
<InteractivePracticeBlock
block={block}
disabled={context.disabled}
onSubmit={context.onPracticeSubmit}
/>
)
}
return (
<article className="rounded-xl border-l-4 border-l-emerald-400 bg-white p-6 shadow-sm">
{block.title && (
<div className="mb-3 flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-emerald-500" />
<h2 className="text-lg font-semibold">{block.title}</h2>
</div>
)}
<div className="text-base leading-relaxed">{renderParagraphs(block.body)}</div>
</article>
)
},

// Scaffolded steps
scaffolded_steps: (block) => (
Expand Down Expand Up @@ -132,7 +160,7 @@ const blockRenderers: Record<string, (block: GeneratedBlock) => React.JSX.Elemen
</article>
),

exposition: (block) => blockRenderers['conceptual_explanation'](block),
exposition: (block, context) => blockRenderers['conceptual_explanation'](block, context),

// Default / unknown kind — clean prose
default: (block) => (
Expand Down
128 changes: 128 additions & 0 deletions frontend/src/components/content/InteractivePracticeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(null)
const [selectedOptionId, setSelectedOptionId] = useState<string | null>(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 (
<article className="rounded-xl border-l-4 border-l-emerald-400 bg-white p-6 shadow-sm">
{block.title && <h2 className="mb-3 text-lg font-semibold">{block.title}</h2>}
{block.body && <p className="mb-4 text-sm leading-6 text-slate-600">{block.body}</p>}

<div className="space-y-3">
<p className="text-base font-medium leading-7 text-slate-900">{interaction.prompt}</p>
{interaction.options.map((option) => {
const selected = option.option_id === selectedOptionId
return (
<button
key={option.option_id}
type="button"
disabled={disabled}
onClick={() => {
if (startedAt.current === null) {
startedAt.current = Date.now()
}
setSelectedOptionId(option.option_id)
}}
className={[
'w-full rounded-xl border px-4 py-4 text-left transition-colors',
selected
? 'border-emerald-500 bg-emerald-50 shadow-sm'
: 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-white',
disabled ? 'cursor-not-allowed opacity-70' : '',
].join(' ')}
>
<div className="flex items-start gap-3">
{selected ? (
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" />
) : (
<Circle className="mt-0.5 h-5 w-5 shrink-0 text-slate-400" />
)}
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">{option.label}</p>
<p className="whitespace-pre-line text-sm leading-6 text-slate-700">
{option.body}
</p>
</div>
</div>
</button>
)
})}
</div>

{selectedOption && reveal && (
<div className="mt-5 rounded-xl border bg-emerald-50/60 p-4">
<p className="text-sm font-semibold text-emerald-900">Verify your reasoning</p>
<p className="mt-2 text-sm leading-6 text-emerald-950">{reveal.prompt}</p>
{reveal.support && (
<p className="mt-3 text-xs leading-5 text-emerald-800">{reveal.support}</p>
)}
<Textarea
value={responseText}
onChange={(event) => setResponseText(event.target.value)}
disabled={disabled}
placeholder={reveal.placeholder ?? 'Explain your thinking.'}
className="mt-4 min-h-[110px] resize-none bg-white"
/>
</div>
)}

<Button
type="button"
onClick={() => {
if (!selectedOptionId) {
return
}
onSubmit({
blockId: block.block_id ?? block.title,
selectedOptionId,
isCorrect: selectedOptionId === interaction.correct_option_id,
responseText: responseText.trim(),
responseTimeMs: Date.now() - (startedAt.current ?? Date.now()),
hintsUsed: reveal?.support ? 1 : 0,
})
}}
disabled={disabled || !canSubmit}
className="mt-5 w-full"
size="lg"
>
Submit and continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</article>
)
}
Loading
Loading