Add safety dashboard and decision transparency
This commit is contained in:
@@ -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],
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user