homeroom-copilot / src /root_cause.py
chand2345's picture
Add root cause analysis module
01d7b1e
Raw
History Blame Contribute Delete
5.37 kB
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,
)