Add safety dashboard and decision transparency
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-17 18:26:49 +02:00
parent ca253d1e6c
commit 0101596e93
12 changed files with 792 additions and 23 deletions

View File

@@ -12,8 +12,11 @@ from app.actuators.models import (
BehaviorPrediction,
BehaviorState,
BehaviorStatus,
DecisionFactor,
ExecutionEvent,
RelatedAutomation,
SafetyProfile,
SafetyStage,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
@@ -175,6 +178,10 @@ class BehaviorEngine:
"patterns": patterns[-_MAX_PATTERNS:],
"last_trained_at": now,
"reason": reason,
"sample_trend": [*record.behavior.sample_trend, len(patterns)][-30:],
"knowledge": _knowledge_lines(record, len(patterns), trusted_actions),
"assumptions": _assumption_lines(record),
"uncertainties": _uncertainty_lines(record, len(patterns), trusted_actions),
}
)
return self._save_behavior(record, behavior)
@@ -268,16 +275,25 @@ class BehaviorEngine:
timezone_name=self._settings.timezone,
)
if prediction is not None:
safety_allowed, safety_blockers = self._assess_safety(
record,
actuator.state,
prediction,
now,
)
prediction = prediction.model_copy(
update={
"execution_reason": self._prediction_execution_reason(
record,
actuator.state,
prediction,
now,
"execution_reason": (
"Ausführung ist freigegeben."
if safety_allowed
else "Nicht ausgeführt: " + " ".join(safety_blockers)
)
}
)
else:
safety_allowed = False
safety_blockers = ["Keine fällige Vorhersage."]
decision_factors = _decision_factors_for(record, current_context, prediction)
behavior = record.behavior.model_copy(
update={
"last_evaluated_at": now,
@@ -287,18 +303,21 @@ class BehaviorEngine:
if prediction is not None
else "Aktuell ist kein gelerntes Handlungsmuster fällig."
),
"decision_factors": decision_factors,
"knowledge": _knowledge_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count),
"assumptions": _assumption_lines(record),
"uncertainties": _uncertainty_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count),
"safety_blockers": safety_blockers if prediction is not None else [],
"confidence_trend": (
[*record.behavior.confidence_trend, round(prediction.confidence, 4)][-30:]
if prediction is not None
else record.behavior.confidence_trend
),
}
)
if (
prediction is not None
and behavior.mode is BehaviorMode.ACTIVE
and prediction.confidence >= self._settings.prediction_confidence
and actuator.state != prediction.target_state
and self._cooldown_elapsed(
behavior,
now,
prediction.target_state,
)
and safety_allowed
):
domain = actuator_entity_id.split(".", 1)[0]
service = service_for_state(domain, prediction.target_state)
@@ -403,6 +422,8 @@ 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
else:
target = prediction.target_state if prediction is not None else None
if target:
@@ -431,6 +452,8 @@ 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
behavior = record.behavior.model_copy(
update={
"patterns": patterns[-_MAX_PATTERNS:],
@@ -441,6 +464,23 @@ class BehaviorEngine:
),
"reason": reason,
"last_trained_at": now,
"correct_feedback_count": correct_count,
"incorrect_feedback_count": incorrect_count,
}
)
return self._save_behavior(record, behavior)
def set_safety_profile(
self,
actuator_entity_id: str,
*,
profile: SafetyProfile,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
behavior = record.behavior.model_copy(
update={
"safety": profile.model_copy(update={"updated_at": datetime.now(timezone.utc)}),
"reason": "Sicherheitsprofil wurde manuell aktualisiert.",
}
)
return self._save_behavior(record, behavior)
@@ -527,6 +567,9 @@ class BehaviorEngine:
update={
"mode": mode,
"approved_at": approved_at,
"safety": record.behavior.safety.model_copy(
update={"stage": SafetyStage.ACTIVE, "updated_at": now}
),
"reason": (
"Autonomes Lernen und Schalten wurde ausdrücklich freigegeben."
),
@@ -607,6 +650,9 @@ class BehaviorEngine:
update={
"mode": mode,
"approved_at": approved_at,
"safety": record.behavior.safety.model_copy(
update={"stage": SafetyStage.SHADOW, "updated_at": now}
),
"related_automations": [
automation.model_copy(update={"enabled": True})
if (
@@ -651,6 +697,51 @@ class BehaviorEngine:
return "Nicht ausgeführt: Sicherheits-Cooldown ist noch aktiv."
return "Ausführung ist freigegeben."
def _assess_safety(
self,
record: ActuatorRecord,
current_state: str | None,
prediction: BehaviorPrediction,
now: datetime,
) -> tuple[bool, list[str]]:
profile = record.behavior.safety
blockers: list[str] = []
domain = record.actuator_entity_id.split(".", 1)[0]
if not record.enabled:
blockers.append("Aktor ist in SillyHome deaktiviert.")
if domain not in _SAFE_ACTIVE_DOMAINS:
blockers.append(f"Domain {domain} ist nicht für autonomes Schalten freigegeben.")
if profile.manual_block:
blockers.append("Manuelle Sicherheitssperre ist aktiv.")
stage = profile.stage
if (
record.behavior.mode is BehaviorMode.ACTIVE
and profile.updated_at is None
and stage is SafetyStage.SHADOW
):
stage = SafetyStage.ACTIVE
if stage not in {SafetyStage.ACTIVE, SafetyStage.PARTIAL}:
blockers.append(f"Safety-Stufe {stage.value} erlaubt noch kein Schalten.")
if record.behavior.mode is not BehaviorMode.ACTIVE:
blockers.append("SillyHome ist im Shadow-Modus.")
if not record.behavior.activation_ready:
blockers.append(record.behavior.activation_reason)
threshold = _confidence_threshold_for(profile, prediction.target_state)
if prediction.confidence < threshold:
blockers.append(
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
)
if current_state == prediction.target_state:
blockers.append("Zielzustand ist bereits erreicht.")
if not self._cooldown_elapsed(
record.behavior,
now,
prediction.target_state,
cooldown_seconds=profile.cooldown_seconds,
):
blockers.append("Sicherheits-Cooldown ist noch aktiv.")
return not blockers, blockers
def _build_patterns(
self,
*,
@@ -701,6 +792,8 @@ class BehaviorEngine:
behavior: BehaviorState,
now: datetime,
target_state: str,
*,
cooldown_seconds: int | None = None,
) -> bool:
if behavior.last_executed_at is None:
return True
@@ -708,7 +801,9 @@ class BehaviorEngine:
if last_event is not None and last_event.target_state != target_state:
return True
return (now - behavior.last_executed_at) >= timedelta(
seconds=self._settings.execution_cooldown_seconds
seconds=cooldown_seconds
if cooldown_seconds is not None
else self._settings.execution_cooldown_seconds
)
def _save_behavior(
@@ -791,6 +886,110 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
return parsed
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
if target_state in {"off", "closed"} and profile.min_confidence_off is not None:
return profile.min_confidence_off
return profile.min_confidence
def _decision_factors_for(
record: ActuatorRecord,
current_context: dict[str, str | None],
prediction: BehaviorPrediction | None,
) -> list[DecisionFactor]:
factors: list[DecisionFactor] = []
candidates = {
candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
for entity_id, state in current_context.items():
candidate = candidates.get(entity_id)
weight = candidate.effective_weight if candidate is not None else 1.0
relevance = candidate.confidence if candidate is not None else 0.5
contribution = round(min(1.0, weight * relevance), 4)
factors.append(
DecisionFactor(
entity_id=entity_id,
label=(
candidate.friendly_name
if candidate is not None and candidate.friendly_name
else entity_id
),
factor_type="context",
state=state,
weight=round(weight, 4),
contribution=contribution,
evidence=(
candidate.evidence[:4]
if candidate is not None
else ["Aktuell ausgewähltes Kontextsignal."]
),
)
)
if prediction is not None:
factors.append(
DecisionFactor(
label=f"Vorhersage {prediction.target_state}",
factor_type="prediction",
state=prediction.target_state,
weight=1.0,
contribution=prediction.confidence,
evidence=[prediction.reason],
)
)
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
def _knowledge_lines(
record: ActuatorRecord,
sample_count: int,
trusted_actions: int,
) -> list[str]:
lines = [
f"{sample_count} historische Aktorhandlungen sind ausgewertet.",
f"{trusted_actions} Handlungen stammen eindeutig von Nutzer oder HA-Automationen.",
]
if record.assignment.selected_numeric_entity_id:
lines.append(f"Hauptsensor: {record.assignment.selected_numeric_entity_id}.")
if record.assignment.selected_context_entity_ids:
lines.append(
f"{len(record.assignment.selected_context_entity_ids)} Kontextsignale sind verbunden."
)
return lines
def _assumption_lines(record: ActuatorRecord) -> list[str]:
lines = [
"Ähnliche Zeitfenster und ähnliche Kontextzustände deuten auf ähnliche Nutzerabsicht hin."
]
if record.manual_override is not None:
lines.append("Manuelle Sensor-/Kontextkorrekturen werden höher gewichtet.")
if record.behavior.related_automations:
lines.append("Passende HA-Automationen gelten als starker Hinweis auf vorhandene Logik.")
return lines
def _uncertainty_lines(
record: ActuatorRecord,
sample_count: int,
trusted_actions: int,
) -> list[str]:
lines: list[str] = []
if sample_count < trusted_actions + 3:
lines.append("Noch wenig Varianz in den gelernten Handlungen.")
if trusted_actions < sample_count:
lines.append("Ein Teil der Handlungen ist nicht eindeutig Nutzer oder Automation zugeordnet.")
if record.assignment.review_required:
lines.append("Die automatische Kontextzuordnung verlangt noch Prüfung.")
if record.behavior.incorrect_feedback_count:
lines.append(
f"{record.behavior.incorrect_feedback_count} negative Feedbacks senken Vertrauen."
)
return lines or ["Keine kritische Unsicherheit aus den lokalen Daten erkannt."]
def predict_behavior(
patterns: list[BehaviorPattern],
*,