Files
sillyhome-next/app/actuators/models.py
Otto 08e41b0198
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
Improve learning discovery and dashboard i18n
2026-07-26 21:57:57 +02:00

413 lines
16 KiB
Python

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 FeedbackKind(StrEnum):
CORRECT = "correct"
WRONG = "wrong"
TOO_EARLY = "too_early"
TOO_LATE = "too_late"
NEVER_AUTOMATE = "never_automate"
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)
target_attributes: dict[str, object] = Field(default_factory=dict)
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
trigger_delay_seconds: int | None = Field(default=None, ge=0)
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
target_attributes: dict[str, object] = Field(default_factory=dict)
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 SimulationOutcome(BaseModel):
scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
actuator_entity_id: str
sensor_states: dict[str, str] = Field(default_factory=dict)
sensor_weights: dict[str, float] = Field(default_factory=dict)
prediction: BehaviorPrediction | None = None
decision_factors: list[DecisionFactor] = Field(default_factory=list)
would_execute: bool = False
blockers: list[str] = Field(default_factory=list)
score: float = Field(default=0.0, ge=0.0, le=1.0)
recommendation: str = Field(default="", max_length=700)
class DecisionTrace(BaseModel):
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
trigger_state: str | None = None
target_state: str | None = None
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
executed: bool = False
blocked: bool = False
reason: str = Field(default="", max_length=700)
blockers: list[str] = Field(default_factory=list)
duration_ms: int | None = Field(default=None, ge=0)
class LatencyMeasurement(BaseModel):
measured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
event_to_decision_ms: int | None = Field(default=None, ge=0)
decision_to_service_ms: int | None = Field(default=None, ge=0)
event_to_done_ms: int | None = Field(default=None, ge=0)
executed: bool = False
source: str = Field(default="manual", max_length=40)
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 ActuatorGroup(BaseModel):
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
name: str = Field(min_length=1, max_length=120)
area_name: str | None = Field(default=None, max_length=120)
member_entity_ids: list[str] = Field(default_factory=list)
reason: str = Field(default="", max_length=300)
class SceneSuggestion(BaseModel):
scene_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=120)
member_entity_ids: list[str] = Field(default_factory=list)
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
reason: str = Field(default="", max_length=500)
last_seen_at: datetime | None = None
class AgentInsight(BaseModel):
insight_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
severity: str = Field(default="info", max_length=20)
title: str = Field(min_length=1, max_length=160)
detail: str = Field(min_length=1, max_length=700)
action: str | None = Field(default=None, max_length=300)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
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)
decision_timeline: list[DecisionTrace] = Field(default_factory=list)
latency_measurements: list[LatencyMeasurement] = Field(default_factory=list)
feedback_log: list[FeedbackKind] = Field(default_factory=list)
dry_run_enabled: bool = False
dry_run_started_at: datetime | None = None
dry_run_sample_count: int = Field(default=0, ge=0)
dry_run_hit_count: int = Field(default=0, ge=0)
actuator_groups: list[ActuatorGroup] = Field(default_factory=list)
scene_suggestions: list[SceneSuggestion] = Field(default_factory=list)
agent_insights: list[AgentInsight] = 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}"