from __future__ import annotations from datetime import datetime, timezone from collections.abc import Callable from app.core.decision import DecisionEngineV2 from app.core.handoff import HandoffMatrix from app.core.models import ( AuditEvent, ControlProfile, Decision, EntityState, LearningProfile, StateEvent, ) from app.core.stores import FutureStores class RoutingIndex: def affected_actuators(self, trigger_entity_id: str, learning: dict[str, LearningProfile]) -> list[str]: result = [ actuator_id for actuator_id, profile in learning.items() if any(pattern.trigger_entity_id == trigger_entity_id for pattern in profile.patterns) ] return sorted(set(result)) class EventCore: def __init__(self, stores: FutureStores) -> None: self._stores = stores self._router = RoutingIndex() self._decision = DecisionEngineV2() self._handoff = HandoffMatrix() def process_state_event( self, event: StateEvent, *, execute: Callable[[Decision], bool] | None = None, ) -> list[AuditEvent]: runtime = self._stores.runtime() learning = self._stores.learning() control = self._stores.control() runtime.entities[event.entity_id] = EntityState( entity_id=event.entity_id, domain=event.entity_id.split(".", 1)[0], state=event.new_state, changed_at=event.changed_at, area_name=event.attributes.get("area_name"), device_id=event.attributes.get("device_id"), friendly_name=event.attributes.get("friendly_name"), ) audit: list[AuditEvent] = [ AuditEvent( event_id=_event_id("state"), kind="state", entity_id=event.entity_id, message=f"{event.entity_id} -> {event.new_state}", ) ] for actuator_id in self._router.affected_actuators(event.entity_id, learning.profiles): learning_profile = learning.profiles[actuator_id] control_profile = control.profiles.get( actuator_id, ControlProfile(actuator_entity_id=actuator_id), ) control_profile = control_profile.model_copy( update={"handoff_mode": self._handoff.classify(control_profile)} ) control.profiles[actuator_id] = control_profile decision = self._decision.decide( actuator_entity_id=actuator_id, trigger_entity_id=event.entity_id, runtime=runtime, learning=learning_profile, control=control_profile, ) if not control.global_enabled and decision.allowed: decision = decision.model_copy( update={ "allowed": False, "reason": "Globaler Not-Aus ist aktiv.", "blockers": [*decision.blockers, "Globaler Not-Aus ist aktiv."], } ) if decision.dry_run: control_profile = _record_dry_run(control_profile, decision) control.profiles[actuator_id] = control_profile if callable(execute) and decision.allowed and not decision.dry_run: decision = _execute_decision(decision, execute) audit.append( AuditEvent( event_id=_event_id("decision"), kind="decision", entity_id=actuator_id, message=decision.reason, decision=decision, ) ) runtime.audit = [*runtime.audit, *audit][-200:] self._stores.save_runtime(runtime) self._stores.save_control(control) return audit def _event_id(prefix: str) -> str: return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')}" def _execute_decision(decision: Decision, execute: Callable[[Decision], bool]) -> Decision: try: result = execute(decision) except Exception as exc: return decision.model_copy( update={ "executed": False, "allowed": False, "reason": f"Ausfuehrung fehlgeschlagen: {exc}", "blockers": [*decision.blockers, str(exc)], } ) return decision.model_copy(update={"executed": bool(result)}) def _record_dry_run(profile: ControlProfile, decision: Decision) -> ControlProfile: events = profile.dry_run_events + 1 successes = profile.dry_run_successes + int(decision.allowed and not decision.blockers) failures = profile.dry_run_failures + int(bool(decision.blockers)) success_rate = successes / events if events else 0.0 ready = events >= 5 and success_rate >= 0.8 and failures <= 1 reason = ( f"Dry-run {successes}/{events} erfolgreich." if ready else f"Dry-run braucht mindestens 5 Events und 80% Treffer; aktuell {successes}/{events}." ) return profile.model_copy( update={ "dry_run_events": events, "dry_run_successes": successes, "dry_run_failures": failures, "active_ready": ready, "active_readiness_reason": reason, } )