Add production diagnostics and planning features
This commit is contained in:
12
CHANGELOG.md
12
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
45
docs/V1_7_0_OPERATING_GUIDE.md
Normal file
45
docs/V1_7_0_OPERATING_GUIDE.md
Normal file
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user