from __future__ import annotations from datetime import datetime, timezone from enum import StrEnum from pydantic import BaseModel, Field from app.ha.discovery import EntityRole class AssignmentSource(StrEnum): NONE = "none" AUTOMATIC = "automatic" MANUAL = "manual" class LifecycleStatus(StrEnum): PENDING_ASSIGNMENT = "pending_assignment" REVIEW_REQUIRED = "review_required" PENDING_HISTORY = "pending_history" TRAINED = "trained" STALE = "stale" INVALID = "invalid" ORPHANED = "orphaned" ARCHIVED = "archived" class BehaviorMode(StrEnum): SHADOW = "shadow" ACTIVE = "active" PAUSED = "paused" class BehaviorStatus(StrEnum): COLLECTING = "collecting" TRAINED = "trained" BLOCKED = "blocked" class SafetyStage(StrEnum): OBSERVE = "observe" SUGGEST = "suggest" SHADOW = "shadow" PARTIAL = "partial" ACTIVE = "active" class JobStatus(StrEnum): PENDING = "pending" RUNNING = "running" COMPLETED = "completed" FAILED = "failed" class AssignmentCandidate(BaseModel): entity_id: str domain: str role: EntityRole device_class: str | None = None state_class: str | None = None unit_of_measurement: str | None = None friendly_name: str | None = None area_name: str | None = None device_name: str | None = None score: float = Field(ge=0.0) confidence: float = Field(ge=0.0, le=1.0) manual_weight: float | None = Field(default=None, ge=0.0, le=1.0) effective_weight: float = Field(default=1.0, ge=0.0, le=1.0) auto_accepted: bool = False evidence: list[str] = Field(default_factory=list) class AssignmentSelection(BaseModel): selected_numeric_entity_id: str | None = None selected_context_entity_ids: list[str] = Field(default_factory=list) source: AssignmentSource = AssignmentSource.NONE confidence: float = Field(default=0.0, ge=0.0, le=1.0) review_required: bool = True reason: str = "Noch keine Zuordnung vorhanden." class SensorWeightGroup(BaseModel): group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$") name: str = Field(min_length=1, max_length=120) entity_ids: list[str] = Field(default_factory=list) weight: float = Field(default=1.0, ge=0.0, le=1.0) class ManualOverride(BaseModel): numeric_entity_id: str | None = None context_entity_ids: list[str] = Field(default_factory=list) sensor_weights: dict[str, float] = Field(default_factory=dict) sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) note: str | None = None class LifecycleAuditEntry(BaseModel): at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) action: str = Field(min_length=1, max_length=120) reason: str = Field(min_length=1, max_length=500) class ModelLifecycleState(BaseModel): model_id: str status: LifecycleStatus = LifecycleStatus.PENDING_ASSIGNMENT last_reconciled_at: datetime | None = None last_trained_at: datetime | None = None last_history_signature: str | None = None last_history_point_count: int = Field(default=0, ge=0) reason: str = "Noch keine Trainingsdaten ausgewertet." next_action: str = "Aktor auswählen; Kontext und Historie werden automatisch geprüft." audit: list[LifecycleAuditEntry] = Field(default_factory=list) class BehaviorPattern(BaseModel): target_state: str = Field(min_length=1, max_length=100) minute_of_day: int = Field(ge=0, le=1439) weekday: int = Field(ge=0, le=6) context_states: dict[str, str] = Field(default_factory=dict) trigger_entity_id: str | None = None trigger_from_state: str | None = None trigger_to_state: str | None = None source: str = Field(default="observed", max_length=40) weight: float = Field(default=1.0, ge=0.1, le=1.0) observed_at: datetime class BehaviorPrediction(BaseModel): target_state: str confidence: float = Field(ge=0.0, le=1.0) generated_at: datetime reason: str matching_patterns: int = Field(default=0, ge=0) executed: bool = False execution_reason: str = "Vorhersage wurde noch nicht ausgeführt." class DecisionFactor(BaseModel): entity_id: str | None = None label: str factor_type: str = Field(max_length=40) state: str | None = None weight: float = Field(default=1.0, ge=0.0, le=1.0) contribution: float = Field(default=0.0, ge=0.0, le=1.0) evidence: list[str] = Field(default_factory=list) class AdaptiveWeightUpdate(BaseModel): entity_id: str previous_weight: float = Field(ge=0.0, le=1.0) new_weight: float = Field(ge=0.0, le=1.0) reason: str = Field(max_length=300) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class SafetyRule(BaseModel): rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$") label: str = Field(min_length=1, max_length=160) enabled: bool = True blocking: bool = True reason: str = Field(default="", max_length=300) def default_safety_rules() -> list[SafetyRule]: return [ SafetyRule( rule_id="activation_ready", label="Nur nach Lernfreigabe aktiv schalten", reason="Der Aktor muss genug eindeutiges Verhalten gelernt haben.", ), SafetyRule( rule_id="confidence_threshold", label="Mindest-Sicherheit einhalten", reason="Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus.", ), SafetyRule( rule_id="cooldown", label="Sicherheits-Cooldown gegen Hin-und-her-Schalten", reason="Gleiche Zielzustände werden nicht zu schnell wiederholt.", ), SafetyRule( rule_id="manual_block", label="Manuelle Sperre respektieren", reason="Nutzer können jeden Aktor sofort blockieren.", ), ] class SafetyProfile(BaseModel): stage: SafetyStage = SafetyStage.SHADOW manual_block: bool = False min_confidence: float = Field(default=0.82, ge=0.0, le=1.0) min_confidence_on: float | None = Field(default=None, ge=0.0, le=1.0) min_confidence_off: float | None = Field(default=None, ge=0.0, le=1.0) cooldown_seconds: int | None = Field(default=None, ge=0) rules: list[SafetyRule] = Field(default_factory=default_safety_rules) updated_at: datetime | None = None note: str | None = Field(default=None, max_length=500) class ExecutionEvent(BaseModel): target_state: str executed_at: datetime class ModelSnapshot(BaseModel): version_id: str created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) sample_count: int = Field(default=0, ge=0) high_confidence_sample_count: int = Field(default=0, ge=0) average_confidence: float = Field(default=0.0, ge=0.0, le=1.0) incorrect_feedback_count: int = Field(default=0, ge=0) patterns: list[BehaviorPattern] = Field(default_factory=list) reason: str = Field(default="", max_length=500) class AutomationConflict(BaseModel): automation_entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$") severity: str = Field(default="info", max_length=20) status: str = Field(default="open", max_length=40) reason: str = Field(max_length=500) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class AnomalyEvent(BaseModel): anomaly_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$") severity: str = Field(default="info", max_length=20) category: str = Field(max_length=40) title: str = Field(min_length=1, max_length=160) detail: str = Field(min_length=1, max_length=500) detected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) resolved: bool = False class TimeProfile(BaseModel): profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$") label: str = Field(min_length=1, max_length=80) sample_count: int = Field(default=0, ge=0) dominant_state: str | None = None confidence: float = Field(default=0.0, ge=0.0, le=1.0) class RelatedAutomation(BaseModel): entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$") config_id: str = Field(min_length=1, max_length=120) friendly_name: str = Field(min_length=1, max_length=200) enabled: bool class BehaviorState(BaseModel): mode: BehaviorMode = BehaviorMode.SHADOW status: BehaviorStatus = BehaviorStatus.COLLECTING approved_at: datetime | None = None sample_count: int = Field(default=0, ge=0) high_confidence_sample_count: int = Field(default=0, ge=0) patterns: list[BehaviorPattern] = Field(default_factory=list) prediction: BehaviorPrediction | None = None last_trained_at: datetime | None = None last_evaluated_at: datetime | None = None last_executed_at: datetime | None = None execution_events: list[ExecutionEvent] = Field(default_factory=list) activation_ready: bool = False activation_reason: str = "Noch nicht genügend Verhalten für eine Freigabe gelernt." related_automations: list[RelatedAutomation] = Field(default_factory=list) paused_automation_entity_ids: list[str] = Field(default_factory=list) reason: str = "Historische Aktorhandlungen werden analysiert." safety: SafetyProfile = Field(default_factory=SafetyProfile) decision_factors: list[DecisionFactor] = Field(default_factory=list) knowledge: list[str] = Field(default_factory=list) assumptions: list[str] = Field(default_factory=list) uncertainties: list[str] = Field(default_factory=list) safety_blockers: list[str] = Field(default_factory=list) sample_trend: list[int] = Field(default_factory=list) confidence_trend: list[float] = Field(default_factory=list) correct_feedback_count: int = Field(default=0, ge=0) incorrect_feedback_count: int = Field(default=0, ge=0) model_snapshots: list[ModelSnapshot] = Field(default_factory=list) active_model_version: str | None = None adaptive_weight_updates: list[AdaptiveWeightUpdate] = Field(default_factory=list) automation_conflicts: list[AutomationConflict] = Field(default_factory=list) time_profiles: list[TimeProfile] = Field(default_factory=list) anomalies: list[AnomalyEvent] = Field(default_factory=list) class ActuatorRecord(BaseModel): actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") enabled: bool = True created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) assignment: AssignmentSelection = Field(default_factory=AssignmentSelection) manual_override: ManualOverride | None = None numeric_candidates: list[AssignmentCandidate] = Field(default_factory=list) context_candidates: list[AssignmentCandidate] = Field(default_factory=list) lifecycle: ModelLifecycleState behavior: BehaviorState = Field(default_factory=BehaviorState) class ReconciliationState(BaseModel): last_started_at: datetime | None = None last_completed_at: datetime | None = None last_trigger: str | None = None running: bool = False configured_actuators: int = Field(default=0, ge=0) review_required: int = Field(default=0, ge=0) trained_models: int = Field(default=0, ge=0) last_summary: str = "Noch keine Reconciliation ausgeführt." class JobQueueItem(BaseModel): job_id: str = Field(min_length=1, max_length=120) kind: str = Field(min_length=1, max_length=40) target: str | None = Field(default=None, max_length=160) trigger: str = Field(default="manual", max_length=40) status: JobStatus = JobStatus.PENDING started_at: datetime | None = None completed_at: datetime | None = None duration_ms: int | None = Field(default=None, ge=0) error: str | None = Field(default=None, max_length=500) summary: str = Field(default="", max_length=500) class JobQueueState(BaseModel): jobs: list[JobQueueItem] = Field(default_factory=list) def model_id_for_actuator(actuator_entity_id: str) -> str: return f"actuator.{actuator_entity_id}"