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
12 changes: 10 additions & 2 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,11 +485,19 @@ export function revokeAuthToken(config: FrontendConfig, refreshToken?: string) {
}

export function getLearnerMasteryHistory(config: FrontendConfig, studentId: string, days = 30) {
return requestJson<MasteryHistoryResponse>(config, `/api/learners/${studentId}/mastery-history?days=${days}`)
return requestJson<MasteryHistoryResponse>(
config,
`/api/learners/${studentId}/mastery-history?days=${days}`,
{ headers: buildHeaders(config, false) },
)
}

export function getSectionMasteryTrends(config: FrontendConfig, sectionId: string, days = 30) {
return requestJson<SectionMasteryTrendsResponse>(config, `/api/teachers/sections/${sectionId}/mastery-trends?days=${days}`)
return requestJson<SectionMasteryTrendsResponse>(
config,
`/api/teachers/sections/${sectionId}/mastery-trends?days=${days}`,
{ headers: buildHeaders(config, false) },
)
}

// ---------------------------------------------------------------------------
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/lib/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,46 @@ export function teacherRemediationPhase(phase: string | null | undefined, backen
export function teacherProgressionAction(action: string | null | undefined, backendLabel?: string | null): string {
return lookup(teacherProgressionActionLabels, action, undefined, backendLabel)
}

// ---------------------------------------------------------------------------
// Mastery / evidence signals
// ---------------------------------------------------------------------------

const teacherMasterySignalLabels: Record<string, string> = {
insufficient: 'Insufficient data',
improving: 'Improving',
stable: 'Stable',
declining: 'Declining',
strong: 'Strong',
weak: 'Weak',
}

const teacherEvidenceSignalLabels: Record<string, string> = {
steady: 'Steady',
improving: 'Improving',
declining: 'Declining',
strong: 'Strong',
weak: 'Weak',
struggling: 'Struggling',
progressing: 'Progressing',
}

const teacherProgressSignalLabels: Record<string, string> = {
insufficient: 'Insufficient data',
advancing: 'Advancing',
stalled: 'Stalled',
regressing: 'Regressing',
steady: 'Steady',
}

export function teacherMasterySignal(signal: string | null | undefined, backendLabel?: string | null): string {
return lookup(teacherMasterySignalLabels, signal, undefined, backendLabel)
}

export function teacherEvidenceSignal(signal: string | null | undefined, backendLabel?: string | null): string {
return lookup(teacherEvidenceSignalLabels, signal, undefined, backendLabel)
}

export function teacherProgressSignal(signal: string | null | undefined, backendLabel?: string | null): string {
return lookup(teacherProgressSignalLabels, signal, undefined, backendLabel)
}
10 changes: 10 additions & 0 deletions frontend/src/sample-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,11 @@ export const demoGenerationHistory: LearnerGenerationHistoryEntry[] = [
active_target_kc_ids: ['KC-1'],
intervention_type: 'targeted_practice',
rationale: 'Generated a final bridge example before the learner moves into transfer practice.',
mastery_signal: 'improving',
mastery_confidence: 0.72,
progress_signal: 'advancing',
evidence_signal: 'progressing',
evidence_rationale: 'Learner showed consistent improvement on fraction equivalence over the last three problems.',
next_step: demoGeneration.workflow_summary?.next_step ?? demoProfileSummary.current_flow.next_step,
continue_action: practiceContinueAction,
created_at: '2026-03-16T08:50:00Z',
Expand All @@ -785,6 +790,11 @@ export const demoGenerationHistory: LearnerGenerationHistoryEntry[] = [
active_target_kc_ids: ['KC-1'],
intervention_type: 'step_back',
rationale: 'Started remediation by rebuilding the prerequisite whole-model concept.',
mastery_signal: 'declining',
mastery_confidence: 0.55,
progress_signal: 'stalled',
evidence_signal: 'struggling',
evidence_rationale: 'Repeated errors on whole-model prerequisite suggest foundational gap.',
next_step: {
action: 'deliver',
content_type: 'worked_example',
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,11 @@ export interface LearnerGenerationHistoryEntry {
active_target_kc_ids: string[]
intervention_type?: string | null
rationale?: string | null
mastery_signal: string
mastery_confidence: number
progress_signal: string
evidence_signal: string
evidence_rationale?: string | null
next_step: LearnerFlowNextStep
continue_action: LearnerContinueAction
created_at: string
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/views/learner/History.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const generations: LearnerGenerationHistoryEntry[] = [
target_stage: 'target',
active_target_kc_ids: [],
rationale: 'Explained fractions',
mastery_signal: 'insufficient',
mastery_confidence: 0,
progress_signal: 'insufficient',
evidence_signal: 'steady',
next_step: nextStep,
continue_action: continueAction,
created_at: '2026-03-17T10:00:00Z',
Expand All @@ -44,6 +48,10 @@ const generations: LearnerGenerationHistoryEntry[] = [
target_stage: 'transfer',
active_target_kc_ids: [],
rationale: 'Practice problems',
mastery_signal: 'improving',
mastery_confidence: 0.8,
progress_signal: 'advancing',
evidence_signal: 'progressing',
next_step: nextStep,
continue_action: continueAction,
created_at: '2026-03-17T09:00:00Z',
Expand Down
45 changes: 43 additions & 2 deletions frontend/src/views/teacher/LearnerDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import {
teacherArtifact,
teacherProgressionAction,
teacherRemediationPhase,
teacherMasterySignal,
teacherEvidenceSignal,
teacherProgressSignal,
} from '../../lib/copy'
import { formatPercent, formatTimestamp, titleCase } from '../../lib/formatters'
import { asMessage } from '../../lib/formatters'
Expand Down Expand Up @@ -516,6 +519,11 @@ function TimelineLabel({ entry }: { entry: TimelineEntry }) {
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{teacherStage(g.target_stage)}
</Badge>
{g.mastery_signal !== 'insufficient' && (
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${masteryBadgeColor(g.mastery_signal)}`}>
{teacherMasterySignal(g.mastery_signal)}
</Badge>
)}
{g.rationale && (
<span className="truncate text-muted-foreground">{g.rationale}</span>
)}
Expand Down Expand Up @@ -556,14 +564,31 @@ function TimelineDetail({ entry }: { entry: TimelineEntry }) {
case 'generation': {
const g = entry.generation!
return (
<div className="ml-10 space-y-1 text-xs text-muted-foreground">
<div className="ml-10 space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span>Flow: {teacherFlowType(g.flow_type)}</span>
<span>Status: {titleCase(g.status)}</span>
<span>Phase: {titleCase(g.delivered_phase)}</span>
<span>Progression: {teacherProgressionAction(g.progression_action)}</span>
</div>
{g.rationale && <p className="italic">{g.rationale}</p>}
{(g.mastery_signal !== 'insufficient' || g.evidence_signal !== 'steady' || g.progress_signal !== 'insufficient') && (
<div className="flex flex-wrap gap-x-4 gap-y-1">
{g.mastery_signal !== 'insufficient' && (
<span>Mastery: {teacherMasterySignal(g.mastery_signal)} ({formatPercent(g.mastery_confidence)})</span>
)}
{g.evidence_signal !== 'steady' && (
<span>Evidence: {teacherEvidenceSignal(g.evidence_signal)}</span>
)}
{g.progress_signal !== 'insufficient' && (
<span>Progress: {teacherProgressSignal(g.progress_signal)}</span>
)}
</div>
)}
{g.evidence_rationale && <p className="italic text-slate-600">{g.evidence_rationale}</p>}
{g.next_step.rationale && (
<p className="italic">Next: {titleCase(g.next_step.action)} &mdash; {g.next_step.rationale}</p>
)}
{g.rationale && !g.evidence_rationale && <p className="italic">{g.rationale}</p>}
</div>
)
}
Expand Down Expand Up @@ -680,6 +705,22 @@ function ArtifactReviewPanel({
// Helpers
// ---------------------------------------------------------------------------

function masteryBadgeColor(signal: string): string {
switch (signal) {
case 'strong':
case 'improving':
return 'border-emerald-300 text-emerald-700'
case 'stable':
case 'steady':
return 'border-sky-300 text-sky-700'
case 'declining':
case 'weak':
return 'border-amber-300 text-amber-700'
default:
return ''
}
}

function StatItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg bg-slate-50 px-3 py-2">
Expand Down
5 changes: 5 additions & 0 deletions src/dibble/models/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ class LearnerGenerationHistoryEntry(BaseModel):
active_target_kc_ids: list[str] = Field(default_factory=list)
intervention_type: str | None = None
rationale: str | None = None
mastery_signal: str = "insufficient"
mastery_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
progress_signal: str = "insufficient"
evidence_signal: str = "steady"
evidence_rationale: str | None = None
next_step: LearnerFlowNextStep = Field(default_factory=LearnerFlowNextStep)
continue_action: LearnerContinueAction = Field(
default_factory=LearnerContinueAction
Expand Down
17 changes: 17 additions & 0 deletions src/dibble/services/learner_history_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def list_generation_history(
for content in entries[:safe_limit]:
workflow_summary = content.workflow_summary
request_context = content.request_context
mode_cal = self._mode_calibration(request_context)
items.append(
LearnerGenerationHistoryEntry(
generation_id=content.generation_id,
Expand Down Expand Up @@ -86,6 +87,15 @@ def list_generation_history(
rationale=workflow_summary.rationale
if workflow_summary is not None
else None,
mastery_signal=mode_cal.get("signal", "insufficient"),
mastery_confidence=float(mode_cal.get("confidence", 0.0)),
progress_signal=mode_cal.get("progress_signal", "insufficient"),
evidence_signal=mode_cal.get(
"current_evidence_signal", "steady"
),
evidence_rationale=self._maybe_str(
mode_cal.get("current_evidence_rationale")
),
next_step=(
workflow_summary.next_step.model_copy()
if workflow_summary is not None
Expand Down Expand Up @@ -190,6 +200,13 @@ def list_remediation_session_history(
has_more=has_more,
)

@staticmethod
def _mode_calibration(request_context: dict[str, object]) -> dict[str, object]:
raw = request_context.get("mode_calibration", {})
if isinstance(raw, dict):
return raw
return {}

@staticmethod
def _maybe_str(value: object) -> str | None:
if value is None:
Expand Down
Loading