from __future__ import annotations from dataclasses import dataclass from typing import Literal TrendDirection = Literal["declining", "improving", "stable", "mixed"] STABLE_TOLERANCE = 2.0 SIGNIFICANT_DECLINE_THRESHOLD = 20.0 @dataclass(frozen=True) class RootCauseInputs: """Input data used to generate root-cause explanations for a student.""" attendance_p1: float attendance_p2: float attendance_p3: float grade_p1: float grade_p2: float grade_p3: float homework_p1: float homework_p2: float homework_p3: float behavior_notes: str | None = None teacher_notes: str | None = None def _format_percentage(value: float) -> str: """Format a percentage without unnecessary decimal places.""" value = float(value) if value.is_integer(): return f"{int(value)}%" return f"{value:.1f}%" def _format_trend_values(values: tuple[float, float, float]) -> str: """Format three reporting-period values for a readable explanation.""" return " → ".join(_format_percentage(value) for value in values) def _trend_direction(values: tuple[float, float, float]) -> TrendDirection: """Classify a three-period trend as declining, improving, stable, or mixed.""" first, second, third = values max_change = max(values) - min(values) if max_change <= STABLE_TOLERANCE: return "stable" if first > second > third: return "declining" if first < second < third: return "improving" return "mixed" def _trend_explanation( label: str, values: tuple[float, float, float], *, declined_noun: str | None = None, ) -> str: """Generate one human-readable explanation for a metric trend.""" direction = _trend_direction(values) formatted_values = _format_trend_values(values) noun = declined_noun or label if direction == "declining": total_drop = values[0] - values[2] if total_drop >= SIGNIFICANT_DECLINE_THRESHOLD: return f"{noun} declined significantly ({formatted_values})." return f"{label} declined across reporting periods ({formatted_values})." if direction == "improving": return f"{label} improved across reporting periods ({formatted_values})." if direction == "stable": return f"{label} remained relatively stable." return f"{label} showed a mixed trend across reporting periods ({formatted_values})." def _clean_note(note: str | None) -> str | None: """Normalize optional note text and treat blanks as missing.""" if note is None: return None cleaned = note.strip() return cleaned or None def generate_root_causes( attendance_p1: float, attendance_p2: float, attendance_p3: float, grade_p1: float, grade_p2: float, grade_p3: float, homework_p1: float, homework_p2: float, homework_p3: float, behavior_notes: str | None = None, teacher_notes: str | None = None, ) -> list[str]: """Generate root-cause explanations from reporting-period metrics and notes. Args: attendance_p1: Attendance percentage for reporting period 1. attendance_p2: Attendance percentage for reporting period 2. attendance_p3: Attendance percentage for reporting period 3. grade_p1: Academic grade percentage for reporting period 1. grade_p2: Academic grade percentage for reporting period 2. grade_p3: Academic grade percentage for reporting period 3. homework_p1: Homework completion percentage for reporting period 1. homework_p2: Homework completion percentage for reporting period 2. homework_p3: Homework completion percentage for reporting period 3. behavior_notes: Optional behavior notes from the student record. teacher_notes: Optional teacher observations from the student record. Returns: A list of human-readable root-cause explanations. """ explanations = [ _trend_explanation( "Attendance", (attendance_p1, attendance_p2, attendance_p3), ), _trend_explanation( "Academic performance", (grade_p1, grade_p2, grade_p3), ), _trend_explanation( "Homework completion", (homework_p1, homework_p2, homework_p3), declined_noun="Homework completion", ), ] cleaned_behavior_notes = _clean_note(behavior_notes) if cleaned_behavior_notes: explanations.append(f"Behavior concerns: {cleaned_behavior_notes}") cleaned_teacher_notes = _clean_note(teacher_notes) if cleaned_teacher_notes: explanations.append(f"Teacher observations: {cleaned_teacher_notes}") return explanations def generate_root_causes_from_inputs(inputs: RootCauseInputs) -> list[str]: """Generate root-cause explanations from a RootCauseInputs instance.""" return generate_root_causes( attendance_p1=inputs.attendance_p1, attendance_p2=inputs.attendance_p2, attendance_p3=inputs.attendance_p3, grade_p1=inputs.grade_p1, grade_p2=inputs.grade_p2, grade_p3=inputs.grade_p3, homework_p1=inputs.homework_p1, homework_p2=inputs.homework_p2, homework_p3=inputs.homework_p3, behavior_notes=inputs.behavior_notes, teacher_notes=inputs.teacher_notes, )