Add production diagnostics and planning features
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-18 11:53:53 +02:00
parent d9dc186f9b
commit 575211f0db
12 changed files with 737 additions and 10 deletions

View File

@@ -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):

View File

@@ -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,
*,

View File

@@ -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,

View File

@@ -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

View File

@@ -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(