diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f7988..f0f4cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.7.0 - 2026-06-18 +- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline, + Event-Latenzmessungen und Dry-run pro Aktor. +- Backup-/Restore-API fuer Aktor-Konfigurationen, Reconciliation-Status und + sichtbare Job-Historie. +- Feedback kann jetzt konkrete Korrekturtypen wie `too_early`, `too_late` und + `never_automate` speichern; `never_automate` setzt eine manuelle Sperre. +- Planungs-Refresh erzeugt Raum-/Aktorgruppen, einfache Szenenvorschlaege und + lokale Agent-Insights aus vorhandenen Daten. +- Event-Verarbeitung laedt Aktor-Konfigurationen nur noch einmal pro + Home-Assistant-State-Change. + ## 1.6.1 - 2026-06-18 - Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping. Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren diff --git a/README.md b/README.md index d21f69d..8dfe340 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ nach einer ausdrücklichen Freigabe ausführen. [`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md) - Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard: [`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md) +- Version 1.7.0 Diagnose, Backup, Dry-run und Planung: + [`docs/V1_7_0_OPERATING_GUIDE.md`](docs/V1_7_0_OPERATING_GUIDE.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) ## Reifegrad diff --git a/addon/config.yaml b/addon/config.yaml index 8046a64..66336d0 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.6.1" +version: "1.7.0" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/actuators/models.py b/app/actuators/models.py index 0d52729..2d74730 100644 --- a/app/actuators/models.py +++ b/app/actuators/models.py @@ -52,6 +52,14 @@ class JobStatus(StrEnum): 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 @@ -146,6 +154,30 @@ class DecisionFactor(BaseModel): evidence: list[str] = Field(default_factory=list) +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) @@ -248,6 +280,32 @@ class RelatedAutomation(BaseModel): 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 @@ -281,6 +339,16 @@ class BehaviorState(BaseModel): 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): diff --git a/app/actuators/store.py b/app/actuators/store.py index c37fa35..51c4be9 100644 --- a/app/actuators/store.py +++ b/app/actuators/store.py @@ -100,6 +100,11 @@ class ActuatorStore: except ValueError as exc: raise ValueError("Ungültiger Job-Queue-Status.") from exc + def save_job_queue(self, queue: JobQueueState) -> JobQueueState: + with self._lock: + self._persist_job_queue(queue) + return queue + def start_job( self, *, diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 34eb409..f26f8a8 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, Field from app.actuators.cache_db import DashboardCache from app.actuators.lifecycle import ActuatorReconciliationService -from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup +from app.actuators.models import ActuatorRecord, AnomalyEvent, FeedbackKind, ReconciliationState, SensorWeightGroup from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine @@ -55,6 +55,29 @@ class WeightOverrideRequest(BaseModel): class FeedbackRequest(BaseModel): correct: bool expected_state: str | None = Field(default=None, max_length=100) + kind: FeedbackKind | None = None + + +class DryRunRequest(BaseModel): + enabled: bool + + +class BackupPayload(BaseModel): + exported_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + records: list[ActuatorRecord] = Field(default_factory=list) + reconciliation: ReconciliationState = Field(default_factory=ReconciliationState) + jobs: JobQueueState = Field(default_factory=JobQueueState) + + +class RestoreRequest(BaseModel): + backup: BackupPayload + replace_existing: bool = False + + +class RestoreResult(BaseModel): + restored_records: int = 0 + skipped_existing: int = 0 + restored_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class SafetyProfileRequest(BaseModel): @@ -406,6 +429,48 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]: return overview +@router.get("/backup/export", response_model=BackupPayload) +def export_backup(request: Request) -> BackupPayload: + store = getattr(request.app.state, "actuator_store", None) + if not isinstance(store, ActuatorStore): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Actuator Store nicht initialisiert.", + ) + return BackupPayload( + records=store.list(), + reconciliation=store.load_reconciliation_state(), + jobs=store.load_job_queue(), + ) + + +@router.post("/backup/restore", response_model=RestoreResult) +def restore_backup(payload: RestoreRequest, request: Request) -> RestoreResult: + store = getattr(request.app.state, "actuator_store", None) + if not isinstance(store, ActuatorStore): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Actuator Store nicht initialisiert.", + ) + existing_ids = {record.actuator_entity_id for record in store.list()} + restored = 0 + skipped = 0 + for record in payload.backup.records: + if record.actuator_entity_id in existing_ids and not payload.replace_existing: + skipped += 1 + continue + store.upsert(record) + restored += 1 + store.save_reconciliation_state(payload.backup.reconciliation) + store.save_job_queue(payload.backup.jobs) + return RestoreResult(restored_records=restored, skipped_existing=skipped) + + +@router.post("/planning/refresh", response_model=list[ActuatorRecord]) +def refresh_planning_insights(request: Request) -> list[ActuatorRecord]: + return _behavior(request).refresh_planning_insights() + + @router.get("", response_model=list[ActuatorRecord]) def list_configured(request: Request) -> list[ActuatorRecord]: return _service(request).list_configured() @@ -513,11 +578,24 @@ def record_feedback( actuator_entity_id, correct=payload.correct, expected_state=payload.expected_state, + kind=payload.kind, ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc +@router.post("/{actuator_entity_id}/dry-run", response_model=ActuatorRecord) +def set_dry_run( + actuator_entity_id: str, + payload: DryRunRequest, + request: Request, +) -> ActuatorRecord: + try: + return _behavior(request).set_dry_run(actuator_entity_id, enabled=payload.enabled) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord) def set_safety_profile( actuator_entity_id: str, diff --git a/app/behavior/engine.py b/app/behavior/engine.py index 913604b..c6c7c89 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -3,12 +3,15 @@ from __future__ import annotations import logging from collections.abc import Sequence from datetime import datetime, timedelta, timezone +from time import perf_counter from zoneinfo import ZoneInfo from app.actuators.models import ( ActuatorRecord, AdaptiveWeightUpdate, + AgentInsight, AnomalyEvent, + ActuatorGroup, AutomationConflict, BehaviorMode, BehaviorPattern, @@ -16,12 +19,16 @@ from app.actuators.models import ( BehaviorState, BehaviorStatus, DecisionFactor, + DecisionTrace, ExecutionEvent, + FeedbackKind, + LatencyMeasurement, ManualOverride, ModelSnapshot, RelatedAutomation, SafetyProfile, SafetyStage, + SceneSuggestion, TimeProfile, ) from app.actuators.store import ActuatorStore @@ -35,6 +42,9 @@ _MAX_PATTERNS = 500 _MAX_MODEL_SNAPSHOTS = 3 _MAX_SNAPSHOT_PATTERNS = 120 _MAX_EXECUTION_EVENTS = 100 +_MAX_DECISION_TRACES = 30 +_MAX_LATENCY_MEASUREMENTS = 50 +_MAX_FEEDBACK_LOG = 50 _ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10) _CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3) _OWN_ACTION_TOLERANCE = timedelta(seconds=20) @@ -254,7 +264,11 @@ class BehaviorEngine: context_state_overrides: dict[str, str | None] | None = None, context_changed_at_overrides: dict[str, datetime | None] | None = None, current_entities: Sequence[HaEntitySummary] | None = None, + trigger_entity_id: str | None = None, + trigger_state: str | None = None, + event_received_at: datetime | None = None, ) -> ActuatorRecord: + started_perf = perf_counter() record = self._store.get(actuator_entity_id) now = datetime.now(timezone.utc) if current_entities is None: @@ -344,6 +358,7 @@ class BehaviorEngine: else: safety_allowed = False safety_blockers = ["Keine fällige Vorhersage."] + decision_to_service_ms: int | None = None decision_factors = _decision_factors_for(record, current_context, prediction) behavior = record.behavior.model_copy( update={ @@ -383,12 +398,47 @@ class BehaviorEngine: domain = actuator_entity_id.split(".", 1)[0] service = service_for_state(domain, prediction.target_state) if service is not None: + if record.behavior.dry_run_enabled: + behavior = behavior.model_copy( + update={ + "prediction": prediction.model_copy( + update={ + "executed": False, + "execution_reason": ( + "Dry-run: Aktion wäre ausgeführt worden." + ), + } + ), + "dry_run_sample_count": record.behavior.dry_run_sample_count + 1, + "reason": ( + f"Dry-run hätte {prediction.target_state!r} mit " + f"{prediction.confidence:.0%} Sicherheit ausgeführt." + ), + } + ) + return self._save_behavior( + record, + _append_decision_trace( + behavior, + trigger_entity_id=trigger_entity_id, + trigger_state=trigger_state, + prediction=prediction, + safety_blockers=safety_blockers, + duration_ms=_elapsed_ms(started_perf), + event_received_at=event_received_at, + decision_to_service_ms=None, + executed=False, + source="event" if event_received_at is not None else "manual", + ), + ) try: + service_started_perf = perf_counter() self._ha_reader.call_service( domain, service, {"entity_id": actuator_entity_id}, ) + decision_to_service_ms = _elapsed_ms(service_started_perf) except (HaClientError, ValueError) as exc: logger.error( "Predicted action failed for %s: %s", @@ -400,7 +450,21 @@ class BehaviorEngine: "reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}" } ) - return self._save_behavior(record, behavior) + return self._save_behavior( + record, + _append_decision_trace( + behavior, + trigger_entity_id=trigger_entity_id, + trigger_state=trigger_state, + prediction=prediction, + safety_blockers=[str(exc)], + duration_ms=_elapsed_ms(started_perf), + event_received_at=event_received_at, + decision_to_service_ms=None, + executed=False, + source="event" if event_received_at is not None else "manual", + ), + ) event = ExecutionEvent( target_state=prediction.target_state, executed_at=now, @@ -434,6 +498,20 @@ class BehaviorEngine: ) } ) + behavior = _append_decision_trace( + behavior, + trigger_entity_id=trigger_entity_id, + trigger_state=trigger_state, + prediction=prediction, + safety_blockers=safety_blockers, + duration_ms=_elapsed_ms(started_perf), + event_received_at=event_received_at, + decision_to_service_ms=( + decision_to_service_ms + ), + executed=bool(prediction is not None and behavior.prediction is not None and behavior.prediction.executed), + source="event" if event_received_at is not None else "manual", + ) return self._save_behavior(record, behavior) def record_feedback( @@ -442,6 +520,7 @@ class BehaviorEngine: *, correct: bool, expected_state: str | None = None, + kind: FeedbackKind | None = None, ) -> ActuatorRecord: record = self._store.get(actuator_entity_id) now = datetime.now(timezone.utc) @@ -485,6 +564,7 @@ class BehaviorEngine: reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt." correct_count = record.behavior.correct_feedback_count + 1 incorrect_count = record.behavior.incorrect_feedback_count + feedback_kind = kind or FeedbackKind.CORRECT else: target = prediction.target_state if prediction is not None else None if target: @@ -515,11 +595,24 @@ class BehaviorEngine: reason = "Vorhersage wurde vom Nutzer als falsch markiert." correct_count = record.behavior.correct_feedback_count incorrect_count = record.behavior.incorrect_feedback_count + 1 + feedback_kind = kind or FeedbackKind.WRONG + if feedback_kind is FeedbackKind.NEVER_AUTOMATE: + safety = record.behavior.safety.model_copy( + update={ + "manual_block": True, + "updated_at": now, + "note": "Durch Nutzerfeedback dauerhaft blockiert.", + } + ) + else: + safety = record.behavior.safety adaptive_updates, manual_override = _adapt_sensor_weights( record, current_context, correct=correct, ) + if correct and prediction is not None: + safety = record.behavior.safety behavior = record.behavior.model_copy( update={ "patterns": patterns[-_MAX_PATTERNS:], @@ -532,6 +625,11 @@ class BehaviorEngine: "last_trained_at": now, "correct_feedback_count": correct_count, "incorrect_feedback_count": incorrect_count, + "feedback_log": [ + *record.behavior.feedback_log, + feedback_kind, + ][-_MAX_FEEDBACK_LOG:], + "safety": safety, "adaptive_weight_updates": [ *record.behavior.adaptive_weight_updates, *adaptive_updates, @@ -557,6 +655,43 @@ class BehaviorEngine: ) return self._save_behavior(record_for_save, behavior) + def set_dry_run(self, actuator_entity_id: str, *, enabled: bool) -> ActuatorRecord: + record = self._store.get(actuator_entity_id) + now = datetime.now(timezone.utc) + behavior = record.behavior.model_copy( + update={ + "dry_run_enabled": enabled, + "dry_run_started_at": now if enabled else record.behavior.dry_run_started_at, + "reason": ( + "Dry-run aktiv; freigegebene Aktionen werden protokolliert, aber nicht geschaltet." + if enabled + else "Dry-run beendet." + ), + } + ) + return self._save_behavior(record, behavior) + + def refresh_planning_insights(self) -> list[ActuatorRecord]: + records = self._store.list() + groups = _derive_actuator_groups(records) + scenes = _derive_scene_suggestions(records) + insights_by_actuator = _derive_agent_insights(records) + updated: list[ActuatorRecord] = [] + for record in records: + behavior = record.behavior.model_copy( + update={ + "actuator_groups": [ + group for group in groups if record.actuator_entity_id in group.member_entity_ids + ], + "scene_suggestions": [ + scene for scene in scenes if record.actuator_entity_id in scene.member_entity_ids + ], + "agent_insights": insights_by_actuator.get(record.actuator_entity_id, []), + } + ) + updated.append(self._save_behavior(record, behavior)) + return updated + def rollback_model( self, actuator_entity_id: str, @@ -966,11 +1101,19 @@ class BehaviorEngine: - Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem WebSocket-State-Cache statt aus einer frischen REST-Abfrage. """ + event_received_at = datetime.now(timezone.utc) + records = self._store.list() # Aktor direkt evaluieren - for record in self._store.list(): + for record in records: if record.actuator_entity_id == entity_id: try: - self.evaluate(record.actuator_entity_id, current_entities=current_entities) + self.evaluate( + record.actuator_entity_id, + current_entities=current_entities, + trigger_entity_id=entity_id, + trigger_state=_event_state(new_state), + event_received_at=event_received_at, + ) except Exception: logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id) return @@ -979,7 +1122,7 @@ class BehaviorEngine: # Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen affected_actuators = [ record.actuator_entity_id - for record in self._store.list() + for record in records if ( record.assignment.selected_numeric_entity_id == entity_id or entity_id in record.assignment.selected_context_entity_ids @@ -992,6 +1135,9 @@ class BehaviorEngine: context_state_overrides={entity_id: event_state}, context_changed_at_overrides={entity_id: event_changed_at}, current_entities=current_entities, + trigger_entity_id=entity_id, + trigger_state=event_state, + event_received_at=event_received_at, ) except Exception: logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id) @@ -1019,6 +1165,208 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None: return parsed +def _elapsed_ms(started_perf: float) -> int: + return max(0, int((perf_counter() - started_perf) * 1000)) + + +def _append_decision_trace( + behavior: BehaviorState, + *, + trigger_entity_id: str | None, + trigger_state: str | None, + prediction: BehaviorPrediction | None, + safety_blockers: list[str], + duration_ms: int, + event_received_at: datetime | None, + decision_to_service_ms: int | None, + executed: bool, + source: str, +) -> BehaviorState: + now = datetime.now(timezone.utc) + blocked = prediction is None or bool(safety_blockers) + trace = DecisionTrace( + trace_id=f"{now.strftime('%Y%m%d%H%M%S%f')}.{trigger_entity_id or 'manual'}", + created_at=now, + trigger_entity_id=trigger_entity_id, + trigger_state=trigger_state, + target_state=prediction.target_state if prediction is not None else None, + confidence=prediction.confidence if prediction is not None else None, + executed=executed, + blocked=blocked, + reason=( + prediction.execution_reason + if prediction is not None + else behavior.reason + ), + blockers=safety_blockers if prediction is not None else ["Keine fällige Vorhersage."], + duration_ms=duration_ms, + ) + updated = behavior.model_copy( + update={ + "decision_timeline": [ + *behavior.decision_timeline, + trace, + ][-_MAX_DECISION_TRACES:], + } + ) + if event_received_at is None: + return updated + return _append_latency_measurement( + updated, + trigger_entity_id=trigger_entity_id, + event_received_at=event_received_at, + event_to_decision_ms=duration_ms, + decision_to_service_ms=decision_to_service_ms, + executed=executed, + source=source, + ) + + +def _append_latency_measurement( + behavior: BehaviorState, + *, + trigger_entity_id: str | None, + event_received_at: datetime | None, + event_to_decision_ms: int | None, + decision_to_service_ms: int | None, + executed: bool, + source: str, +) -> BehaviorState: + if event_received_at is None: + return behavior + now = datetime.now(timezone.utc) + event_to_done_ms = max(0, int((now - event_received_at).total_seconds() * 1000)) + measurement = LatencyMeasurement( + measured_at=now, + trigger_entity_id=trigger_entity_id, + event_to_decision_ms=event_to_decision_ms, + decision_to_service_ms=decision_to_service_ms, + event_to_done_ms=event_to_done_ms, + executed=executed, + source=source, + ) + return behavior.model_copy( + update={ + "latency_measurements": [ + *behavior.latency_measurements, + measurement, + ][-_MAX_LATENCY_MEASUREMENTS:], + } + ) + + +def _derive_actuator_groups(records: list[ActuatorRecord]) -> list[ActuatorGroup]: + by_area: dict[str, list[str]] = {} + for record in records: + area = _area_hint(record) + if area: + by_area.setdefault(area, []).append(record.actuator_entity_id) + return [ + ActuatorGroup( + group_id=_slug(f"area_{area}"), + name=f"Raum {area}", + area_name=area, + member_entity_ids=sorted(entity_ids), + reason="Aktor-Gruppe aus gemeinsamer Raum-/Kontextzuordnung abgeleitet.", + ) + for area, entity_ids in sorted(by_area.items()) + if len(entity_ids) >= 2 + ] + + +def _derive_scene_suggestions(records: list[ActuatorRecord]) -> list[SceneSuggestion]: + scenes: list[SceneSuggestion] = [] + by_context: dict[tuple[str, str], list[str]] = {} + for record in records: + for pattern in record.behavior.patterns: + for entity_id, state in pattern.context_states.items(): + by_context.setdefault((entity_id, state), []).append(record.actuator_entity_id) + for (entity_id, state), members in sorted(by_context.items()): + unique_members = sorted(set(members)) + if len(unique_members) < 2: + continue + scenes.append( + SceneSuggestion( + scene_id=_slug(f"{entity_id}_{state}"), + label=f"{entity_id} ist {state}", + member_entity_ids=unique_members, + confidence=min(1.0, len(members) / max(3, len(unique_members) * 2)), + reason="Mehrere Aktoren reagieren historisch auf denselben Kontext.", + last_seen_at=max( + ( + pattern.observed_at + for record in records + for pattern in record.behavior.patterns + if pattern.context_states.get(entity_id) == state + ), + default=None, + ), + ) + ) + return scenes[-20:] + + +def _derive_agent_insights(records: list[ActuatorRecord]) -> dict[str, list[AgentInsight]]: + result: dict[str, list[AgentInsight]] = {} + for record in records: + insights: list[AgentInsight] = [] + if record.behavior.automation_conflicts: + insights.append( + AgentInsight( + insight_id=f"{record.actuator_entity_id}.automation_conflict", + severity="warning", + title="Automation-Konflikt prüfen", + detail="Eine passende HA-Automation kann parallel zu SillyHome schalten.", + action="Automation pausieren oder SillyHome im Shadow-Modus lassen.", + ) + ) + if record.behavior.latency_measurements: + durations = [ + item.event_to_done_ms + for item in record.behavior.latency_measurements + if item.event_to_done_ms is not None + ] + if durations and max(durations) > 1500: + insights.append( + AgentInsight( + insight_id=f"{record.actuator_entity_id}.latency", + severity="warning", + title="Schalt-Latenz beobachten", + detail=f"Letzte maximale Event-Latenz: {max(durations)} ms.", + action="WebSocket-Status, HA-Servicezeit und Sensor-Routing pruefen.", + ) + ) + if record.behavior.incorrect_feedback_count > record.behavior.correct_feedback_count: + insights.append( + AgentInsight( + insight_id=f"{record.actuator_entity_id}.feedback", + severity="warning", + title="Viele negative Feedbacks", + detail="Das Modell trifft aktuell mehr falsche als richtige Entscheidungen.", + action="Kontextzuordnung, Gewichtung oder Modell-Rollback pruefen.", + ) + ) + result[record.actuator_entity_id] = insights[:5] + return result + + +def _area_hint(record: ActuatorRecord) -> str | None: + for candidate in [*record.context_candidates, *record.numeric_candidates]: + if candidate.area_name: + return candidate.area_name + return None + + +def _slug(value: str) -> str: + result = [] + for char in value.lower(): + if char.isalnum(): + result.append(char) + elif char in {".", "_", "-", " "}: + result.append("_") + return "".join(result).strip("_")[:64] or "item" + + def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float: if target_state == "on" and profile.min_confidence_on is not None: return profile.min_confidence_on diff --git a/app/main.py b/app/main.py index a57d4ec..de6aa2c 100644 --- a/app/main.py +++ b/app/main.py @@ -117,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="1.6.1", + version="1.7.0", lifespan=lifespan, ) app.state.settings = load_settings() @@ -311,8 +311,6 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None: continue new_state = event_data.get("new_state") _update_ha_state_cache(state_cache, entity_id, new_state) - if not _is_relevant_state_change(store, str(entity_id)): - continue # Prüfe, ob Entity ein Aktor oder relevanter Kontext ist # Sofortige Vorhersage für betroffene Aktoren auslösen await asyncio.to_thread( diff --git a/docs/V1_7_0_OPERATING_GUIDE.md b/docs/V1_7_0_OPERATING_GUIDE.md new file mode 100644 index 0000000..1d593db --- /dev/null +++ b/docs/V1_7_0_OPERATING_GUIDE.md @@ -0,0 +1,45 @@ +# SillyHome Next v1.7.0 Operating Guide + +v1.7.0 erweitert den Produktivbetrieb um Diagnose, Backup, Dry-run und +Planungshilfen. + +## Diagnose + +- Jede Auswertung speichert eine kompakte `decision_timeline` am Aktor. +- Event-basierte Auswertungen speichern zusaetzlich `latency_measurements`. +- Die Timeline beantwortet: was war der Ausloeser, welches Ziel wurde + vorhergesagt, wurde geschaltet oder blockiert, und warum. + +## Backup und Restore + +- `GET /v1/actuators/backup/export` exportiert Aktoren, Reconciliation-Status + und Job-Historie als JSON. +- `POST /v1/actuators/backup/restore` spielt diesen Stand wieder ein. +- Ohne `replace_existing=true` werden vorhandene Aktoren nicht ueberschrieben. + +## Dry-run + +- `POST /v1/actuators/{entity_id}/dry-run` aktiviert oder beendet den Testmodus. +- Im Dry-run werden freigegebene Aktionen bewertet und protokolliert, aber nicht + an Home Assistant gesendet. + +## Feedback + +Feedback akzeptiert neben `correct`/`expected_state` nun optionale Typen: + +- `correct` +- `wrong` +- `too_early` +- `too_late` +- `never_automate` + +`never_automate` setzt eine manuelle Sicherheitssperre am Aktor. + +## Planung + +`POST /v1/actuators/planning/refresh` berechnet lokale Hinweise: + +- Aktorgruppen aus gemeinsamen Raum-/Kontextdaten +- einfache Szenenvorschlaege aus gemeinsamem Kontextverhalten +- Agent-Insights fuer Konflikte, Latenz und auffaelliges Feedback + diff --git a/pyproject.toml b/pyproject.toml index 554ca0b..1bf6865 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.6.1" +version = "1.7.0" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index 4e03a85..2872ee5 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -354,6 +354,51 @@ def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) - assert rollback.json()["behavior"]["active_model_version"] == version_id +def test_feedback_never_automate_sets_manual_block(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + client.post( + "/v1/actuators", + json={"actuator_entity_id": "light.abstellkammer"}, + ) + + feedback = client.post( + "/v1/actuators/light.abstellkammer/feedback", + json={"correct": False, "kind": "never_automate"}, + ) + + assert feedback.status_code == 200 + payload = feedback.json() + assert payload["behavior"]["safety"]["manual_block"] is True + assert payload["behavior"]["feedback_log"][-1] == "never_automate" + + +def test_backup_export_restore_and_planning_refresh(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) + + backup = client.get("/v1/actuators/backup/export") + dry_run = client.post( + "/v1/actuators/light.abstellkammer/dry-run", + json={"enabled": True}, + ) + planning = client.post("/v1/actuators/planning/refresh") + restore = client.post( + "/v1/actuators/backup/restore", + json={"backup": backup.json(), "replace_existing": True}, + ) + + assert backup.status_code == 200 + assert backup.json()["records"][0]["actuator_entity_id"] == "light.abstellkammer" + assert dry_run.status_code == 200 + assert dry_run.json()["behavior"]["dry_run_enabled"] is True + assert planning.status_code == 200 + assert "agent_insights" in planning.json()[0]["behavior"] + assert restore.status_code == 200 + assert restore.json()["restored_records"] == 1 + + def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None: with TestClient(app) as client: _install_service(tmp_path) diff --git a/tests/behavior/test_engine.py b/tests/behavior/test_engine.py index 7c41677..58bb7cd 100644 --- a/tests/behavior/test_engine.py +++ b/tests/behavior/test_engine.py @@ -778,3 +778,129 @@ def test_state_change_uses_event_cache_without_rest_state_query( assert reader.service_calls == [ ("light", "turn_on", {"entity_id": "light.storage"}) ] + + +def test_event_evaluation_records_decision_timeline_and_latency( + tmp_path: Path, +) -> None: + now = datetime.now(timezone.utc).replace(microsecond=0) + settings = _settings(tmp_path) + store = ActuatorStore(settings.actuator_store) + record = store.configure("light.storage") + record = record.model_copy( + update={ + "assignment": record.assignment.model_copy( + update={"selected_context_entity_ids": ["binary_sensor.storage_door"]} + ), + "behavior": record.behavior.model_copy( + update={ + "mode": BehaviorMode.ACTIVE, + "status": BehaviorStatus.TRAINED, + "activation_ready": True, + "patterns": [ + BehaviorPattern( + target_state="on", + minute_of_day=60, + weekday=0, + context_states={"binary_sensor.storage_door": "on"}, + trigger_entity_id="binary_sensor.storage_door", + trigger_from_state="off", + trigger_to_state="on", + source="automation", + weight=1.0, + observed_at=now - timedelta(days=days_ago), + ) + for days_ago in (3, 2, 1) + ], + } + ), + } + ) + store.upsert(record) + reader = FakeBehaviorReader( + entities=[ + HaEntitySummary(entity_id="light.storage", domain="light", state="off"), + HaEntitySummary( + entity_id="binary_sensor.storage_door", + domain="binary_sensor", + state="on", + last_changed=now, + ), + ], + history=[], + logbook=[], + ) + engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings) + + result = engine.evaluate( + "light.storage", + trigger_entity_id="binary_sensor.storage_door", + trigger_state="on", + event_received_at=now, + ) + + trace = result.behavior.decision_timeline[-1] + latency = result.behavior.latency_measurements[-1] + assert trace.trigger_entity_id == "binary_sensor.storage_door" + assert trace.target_state == "on" + assert trace.executed is True + assert latency.trigger_entity_id == "binary_sensor.storage_door" + assert latency.executed is True + + +def test_dry_run_records_without_calling_service(tmp_path: Path) -> None: + now = datetime.now(timezone.utc).replace(microsecond=0) + settings = _settings(tmp_path) + store = ActuatorStore(settings.actuator_store) + record = store.configure("light.storage") + record = record.model_copy( + update={ + "assignment": record.assignment.model_copy( + update={"selected_context_entity_ids": ["binary_sensor.storage_door"]} + ), + "behavior": record.behavior.model_copy( + update={ + "mode": BehaviorMode.ACTIVE, + "status": BehaviorStatus.TRAINED, + "activation_ready": True, + "dry_run_enabled": True, + "patterns": [ + BehaviorPattern( + target_state="on", + minute_of_day=60, + weekday=0, + context_states={"binary_sensor.storage_door": "on"}, + trigger_entity_id="binary_sensor.storage_door", + trigger_from_state="off", + trigger_to_state="on", + source="automation", + weight=1.0, + observed_at=now - timedelta(days=days_ago), + ) + for days_ago in (3, 2, 1) + ], + } + ), + } + ) + store.upsert(record) + reader = FakeBehaviorReader( + entities=[ + HaEntitySummary(entity_id="light.storage", domain="light", state="off"), + HaEntitySummary( + entity_id="binary_sensor.storage_door", + domain="binary_sensor", + state="on", + last_changed=now, + ), + ], + history=[], + logbook=[], + ) + engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings) + + result = engine.evaluate("light.storage") + + assert reader.service_calls == [] + assert result.behavior.dry_run_sample_count == 1 + assert result.behavior.decision_timeline[-1].executed is False