diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 08211ea..9d7b561 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -485,11 +485,19 @@ export function revokeAuthToken(config: FrontendConfig, refreshToken?: string) { } export function getLearnerMasteryHistory(config: FrontendConfig, studentId: string, days = 30) { - return requestJson(config, `/api/learners/${studentId}/mastery-history?days=${days}`) + return requestJson( + config, + `/api/learners/${studentId}/mastery-history?days=${days}`, + { headers: buildHeaders(config, false) }, + ) } export function getSectionMasteryTrends(config: FrontendConfig, sectionId: string, days = 30) { - return requestJson(config, `/api/teachers/sections/${sectionId}/mastery-trends?days=${days}`) + return requestJson( + config, + `/api/teachers/sections/${sectionId}/mastery-trends?days=${days}`, + { headers: buildHeaders(config, false) }, + ) } // --------------------------------------------------------------------------- diff --git a/frontend/src/lib/copy.ts b/frontend/src/lib/copy.ts index 4ec7c08..a29100a 100644 --- a/frontend/src/lib/copy.ts +++ b/frontend/src/lib/copy.ts @@ -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 = { + insufficient: 'Insufficient data', + improving: 'Improving', + stable: 'Stable', + declining: 'Declining', + strong: 'Strong', + weak: 'Weak', +} + +const teacherEvidenceSignalLabels: Record = { + steady: 'Steady', + improving: 'Improving', + declining: 'Declining', + strong: 'Strong', + weak: 'Weak', + struggling: 'Struggling', + progressing: 'Progressing', +} + +const teacherProgressSignalLabels: Record = { + 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) +} diff --git a/frontend/src/sample-data.ts b/frontend/src/sample-data.ts index 32f9c5c..6cfe3da 100644 --- a/frontend/src/sample-data.ts +++ b/frontend/src/sample-data.ts @@ -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', @@ -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', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 703ef6d..0cc9226 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 diff --git a/frontend/src/views/learner/History.test.tsx b/frontend/src/views/learner/History.test.tsx index a0a8552..8947528 100644 --- a/frontend/src/views/learner/History.test.tsx +++ b/frontend/src/views/learner/History.test.tsx @@ -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', @@ -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', diff --git a/frontend/src/views/teacher/LearnerDetail.tsx b/frontend/src/views/teacher/LearnerDetail.tsx index b9cb8c1..522eb88 100644 --- a/frontend/src/views/teacher/LearnerDetail.tsx +++ b/frontend/src/views/teacher/LearnerDetail.tsx @@ -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' @@ -516,6 +519,11 @@ function TimelineLabel({ entry }: { entry: TimelineEntry }) { {teacherStage(g.target_stage)} + {g.mastery_signal !== 'insufficient' && ( + + {teacherMasterySignal(g.mastery_signal)} + + )} {g.rationale && ( {g.rationale} )} @@ -556,14 +564,31 @@ function TimelineDetail({ entry }: { entry: TimelineEntry }) { case 'generation': { const g = entry.generation! return ( -
+
Flow: {teacherFlowType(g.flow_type)} Status: {titleCase(g.status)} Phase: {titleCase(g.delivered_phase)} Progression: {teacherProgressionAction(g.progression_action)}
- {g.rationale &&

{g.rationale}

} + {(g.mastery_signal !== 'insufficient' || g.evidence_signal !== 'steady' || g.progress_signal !== 'insufficient') && ( +
+ {g.mastery_signal !== 'insufficient' && ( + Mastery: {teacherMasterySignal(g.mastery_signal)} ({formatPercent(g.mastery_confidence)}) + )} + {g.evidence_signal !== 'steady' && ( + Evidence: {teacherEvidenceSignal(g.evidence_signal)} + )} + {g.progress_signal !== 'insufficient' && ( + Progress: {teacherProgressSignal(g.progress_signal)} + )} +
+ )} + {g.evidence_rationale &&

{g.evidence_rationale}

} + {g.next_step.rationale && ( +

Next: {titleCase(g.next_step.action)} — {g.next_step.rationale}

+ )} + {g.rationale && !g.evidence_rationale &&

{g.rationale}

}
) } @@ -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 (
diff --git a/src/dibble/models/history.py b/src/dibble/models/history.py index 3fd9d15..c9cd905 100644 --- a/src/dibble/models/history.py +++ b/src/dibble/models/history.py @@ -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 diff --git a/src/dibble/services/learner_history_service.py b/src/dibble/services/learner_history_service.py index 64d8f2b..77ef70d 100644 --- a/src/dibble/services/learner_history_service.py +++ b/src/dibble/services/learner_history_service.py @@ -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, @@ -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 @@ -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: