Spaces:
Running
Running
| """Case entity and lifecycle management. | |
| This module defines the Case class which represents a single court case | |
| progressing through various stages. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| from datetime import date, datetime | |
| from enum import Enum | |
| from typing import TYPE_CHECKING, List, Optional | |
| from src.data.config import TERMINAL_STAGES | |
| if TYPE_CHECKING: | |
| from src.core.ripeness import RipenessStatus | |
| else: | |
| # Import at runtime | |
| RipenessStatus = None | |
| class CaseStatus(Enum): | |
| """Status of a case in the system.""" | |
| PENDING = "pending" # Filed, awaiting first hearing | |
| ACTIVE = "active" # Has had at least one hearing | |
| ADJOURNED = "adjourned" # Last hearing was adjourned | |
| DISPOSED = "disposed" # Final disposal/settlement reached | |
| class Case: | |
| """Represents a single court case. | |
| Attributes: | |
| case_id: Unique identifier (like CNR number) | |
| case_type: Type of case (RSA, CRP, RFA, CA, CCC, CP, CMP) | |
| filed_date: Date when case was filed | |
| current_stage: Current stage in lifecycle | |
| status: Current status (PENDING, ACTIVE, ADJOURNED, DISPOSED) | |
| courtroom_id: Assigned courtroom (0-4 for 5 courtrooms) | |
| is_urgent: Whether case is marked urgent | |
| readiness_score: Computed readiness score (0-1) | |
| hearing_count: Number of hearings held | |
| last_hearing_date: Date of most recent hearing | |
| days_since_last_hearing: Days elapsed since last hearing | |
| age_days: Days since filing | |
| disposal_date: Date of disposal (if disposed) | |
| history: List of hearing dates and outcomes | |
| """ | |
| case_id: str | |
| case_type: str | |
| filed_date: date | |
| current_stage: str = "ADMISSION" # Default initial stage | |
| status: CaseStatus = CaseStatus.PENDING | |
| courtroom_id: int | None = None # None = not yet assigned; 0 is invalid | |
| is_urgent: bool = False | |
| readiness_score: float = 0.0 | |
| hearing_count: int = 0 | |
| last_hearing_date: Optional[date] = None | |
| days_since_last_hearing: int = 0 | |
| age_days: int = 0 | |
| disposal_date: Optional[date] = None | |
| stage_start_date: Optional[date] = None | |
| days_in_stage: int = 0 | |
| history: List[dict] = field(default_factory=list) | |
| # Ripeness tracking (NEW - for bottleneck detection) | |
| ripeness_status: str = "UNKNOWN" # RipenessStatus enum value (stored as string to avoid circular import) | |
| bottleneck_reason: Optional[str] = None | |
| ripeness_updated_at: Optional[datetime] = None | |
| last_hearing_purpose: Optional[str] = ( | |
| None # Purpose of last hearing (for classification) | |
| ) | |
| # No-case-left-behind tracking (NEW) | |
| last_scheduled_date: Optional[date] = None | |
| days_since_last_scheduled: int = 0 | |
| def progress_to_stage(self, new_stage: str, current_date: date) -> None: | |
| """Progress case to a new stage. | |
| Args: | |
| new_stage: The stage to progress to | |
| current_date: Current simulation date | |
| """ | |
| self.current_stage = new_stage | |
| self.stage_start_date = current_date | |
| self.days_in_stage = 0 | |
| # Check if terminal stage (case disposed) | |
| if new_stage in TERMINAL_STAGES: | |
| self.status = CaseStatus.DISPOSED | |
| self.disposal_date = current_date | |
| # Record in history | |
| self.history.append( | |
| { | |
| "date": current_date, | |
| "event": "stage_change", | |
| "stage": new_stage, | |
| } | |
| ) | |
| def record_hearing( | |
| self, hearing_date: date, was_heard: bool, outcome: str = "" | |
| ) -> None: | |
| """Record a hearing event. | |
| Args: | |
| hearing_date: Date of the hearing | |
| was_heard: Whether the hearing actually proceeded (not adjourned) | |
| outcome: Outcome description | |
| """ | |
| self.hearing_count += 1 | |
| self.last_hearing_date = hearing_date | |
| if was_heard: | |
| self.status = CaseStatus.ACTIVE | |
| else: | |
| self.status = CaseStatus.ADJOURNED | |
| # Record in history | |
| self.history.append( | |
| { | |
| "date": hearing_date, | |
| "event": "hearing", | |
| "was_heard": was_heard, | |
| "outcome": outcome, | |
| "stage": self.current_stage, | |
| } | |
| ) | |
| def update_age(self, current_date: date) -> None: | |
| """Update age and days since last hearing. | |
| Args: | |
| current_date: Current simulation date | |
| """ | |
| self.age_days = (current_date - self.filed_date).days | |
| if self.last_hearing_date: | |
| self.days_since_last_hearing = (current_date - self.last_hearing_date).days | |
| else: | |
| self.days_since_last_hearing = self.age_days | |
| if self.stage_start_date: | |
| self.days_in_stage = (current_date - self.stage_start_date).days | |
| else: | |
| self.days_in_stage = self.age_days | |
| # Update days since last scheduled (for no-case-left-behind tracking) | |
| if self.last_scheduled_date: | |
| self.days_since_last_scheduled = ( | |
| current_date - self.last_scheduled_date | |
| ).days | |
| else: | |
| self.days_since_last_scheduled = self.age_days | |
| def compute_readiness_score(self) -> float: | |
| """Compute readiness score based on hearings, gaps, and stage. | |
| Formula (from EDA): | |
| READINESS = (hearings_capped/50) * 0.4 + | |
| (100/gap_clamped) * 0.3 + | |
| (stage_advanced) * 0.3 | |
| Returns: | |
| Readiness score (0-1, higher = more ready) | |
| """ | |
| # Cap hearings at 50 | |
| hearings_capped = min(self.hearing_count, 50) | |
| hearings_component = (hearings_capped / 50) * 0.4 | |
| # Gap component (inverse of days since last hearing) | |
| gap_clamped = min(max(self.days_since_last_hearing, 1), 100) | |
| gap_component = (100 / gap_clamped) * 0.3 | |
| # Stage component (advanced stages get higher score) | |
| advanced_stages = ["ARGUMENTS", "EVIDENCE", "ORDERS / JUDGMENT"] | |
| stage_component = 0.3 if self.current_stage in advanced_stages else 0.1 | |
| readiness = hearings_component + gap_component + stage_component | |
| self.readiness_score = min(1.0, max(0.0, readiness)) | |
| return self.readiness_score | |
| def is_ready_for_scheduling(self, min_gap_days: int = 7) -> bool: | |
| """Check if case is ready to be scheduled. | |
| Args: | |
| min_gap_days: Minimum days required since last hearing | |
| Returns: | |
| True if case can be scheduled | |
| """ | |
| if self.status == CaseStatus.DISPOSED: | |
| return False | |
| if self.last_hearing_date is None: | |
| return True # First hearing, always ready | |
| return self.days_since_last_hearing >= min_gap_days | |
| def needs_alert(self, max_gap_days: int = 90) -> bool: | |
| """Check if case needs alert due to long gap. | |
| Args: | |
| max_gap_days: Maximum allowed gap before alert | |
| Returns: | |
| True if alert should be triggered | |
| """ | |
| if self.status == CaseStatus.DISPOSED: | |
| return False | |
| return self.days_since_last_hearing > max_gap_days | |
| def get_priority_score(self) -> float: | |
| """Get overall priority score for scheduling. | |
| Combines age, readiness, urgency, and adjournment boost into single score. | |
| Formula: | |
| priority = age*0.35 + readiness*0.25 + urgency*0.25 + adjournment_boost*0.15 | |
| Adjournment boost: Recently adjourned cases get priority to avoid indefinite postponement. | |
| The boost decays exponentially: strongest immediately after adjournment, weaker over time. | |
| Returns: | |
| Priority score (higher = higher priority) | |
| """ | |
| # Age component (normalize to 0-1, assuming max age ~2000 days) | |
| age_component = min(self.age_days / 2000, 1.0) * 0.35 | |
| # Readiness component | |
| readiness_component = self.readiness_score * 0.25 | |
| # Urgency component | |
| urgency_component = 1.0 if self.is_urgent else 0.0 | |
| urgency_component *= 0.25 | |
| # Adjournment boost (NEW - prevents cases from being repeatedly postponed) | |
| adjournment_boost = 0.0 | |
| if self.status == CaseStatus.ADJOURNED and self.hearing_count > 0: | |
| # Boost starts at 1.0 immediately after adjournment, decays exponentially | |
| # Formula: boost = exp(-days_since_hearing / 21) | |
| # At 7 days: ~0.71 (strong boost) | |
| # At 14 days: ~0.50 (moderate boost) | |
| # At 21 days: ~0.37 (weak boost) | |
| # At 28 days: ~0.26 (very weak boost) | |
| import math | |
| decay_factor = 21 # Half-life of boost | |
| adjournment_boost = math.exp(-self.days_since_last_hearing / decay_factor) | |
| adjournment_boost *= 0.15 | |
| return ( | |
| age_component + readiness_component + urgency_component + adjournment_boost | |
| ) | |
| def mark_unripe(self, status, reason: str, current_date: datetime) -> None: | |
| """Mark case as unripe with bottleneck reason. | |
| Args: | |
| status: Ripeness status (UNRIPE_SUMMONS, UNRIPE_PARTY, etc.) - RipenessStatus enum | |
| reason: Human-readable reason for unripeness | |
| current_date: Current simulation date | |
| """ | |
| # Store as string to avoid circular import | |
| self.ripeness_status = status.value if hasattr(status, "value") else str(status) | |
| self.bottleneck_reason = reason | |
| self.ripeness_updated_at = current_date | |
| # Record in history | |
| self.history.append( | |
| { | |
| "date": current_date, | |
| "event": "ripeness_change", | |
| "status": self.ripeness_status, | |
| "reason": reason, | |
| } | |
| ) | |
| def mark_ripe(self, current_date: datetime) -> None: | |
| """Mark case as ripe (ready for hearing). | |
| Args: | |
| current_date: Current simulation date | |
| """ | |
| self.ripeness_status = "RIPE" | |
| self.bottleneck_reason = None | |
| self.ripeness_updated_at = current_date | |
| # Record in history | |
| self.history.append( | |
| { | |
| "date": current_date, | |
| "event": "ripeness_change", | |
| "status": "RIPE", | |
| "reason": "Case became ripe", | |
| } | |
| ) | |
| def mark_scheduled(self, scheduled_date: date) -> None: | |
| """Mark case as scheduled for a hearing. | |
| Used for no-case-left-behind tracking. | |
| Args: | |
| scheduled_date: Date case was scheduled | |
| """ | |
| self.last_scheduled_date = scheduled_date | |
| self.days_since_last_scheduled = 0 | |
| def is_disposed(self) -> bool: | |
| """Check if case is disposed.""" | |
| return self.status == CaseStatus.DISPOSED | |
| def __repr__(self) -> str: | |
| return ( | |
| f"Case(id={self.case_id}, type={self.case_type}, " | |
| f"stage={self.current_stage}, status={self.status.value}, " | |
| f"hearings={self.hearing_count})" | |
| ) | |
| def to_dict(self) -> dict: | |
| """Convert case to dictionary for serialization.""" | |
| return { | |
| "case_id": self.case_id, | |
| "case_type": self.case_type, | |
| "filed_date": self.filed_date.isoformat(), | |
| "current_stage": self.current_stage, | |
| "status": self.status.value, | |
| "courtroom_id": self.courtroom_id, | |
| "is_urgent": self.is_urgent, | |
| "readiness_score": self.readiness_score, | |
| "hearing_count": self.hearing_count, | |
| "last_hearing_date": self.last_hearing_date.isoformat() | |
| if self.last_hearing_date | |
| else None, | |
| "days_since_last_hearing": self.days_since_last_hearing, | |
| "age_days": self.age_days, | |
| "disposal_date": self.disposal_date.isoformat() | |
| if self.disposal_date | |
| else None, | |
| "ripeness_status": self.ripeness_status, | |
| "bottleneck_reason": self.bottleneck_reason, | |
| "last_hearing_purpose": self.last_hearing_purpose, | |
| "last_scheduled_date": self.last_scheduled_date.isoformat() | |
| if self.last_scheduled_date | |
| else None, | |
| "days_since_last_scheduled": self.days_since_last_scheduled, | |
| "history": self.history, | |
| } | |