Add safety dashboard and decision transparency
This commit is contained in:
@@ -37,6 +37,21 @@ class BehaviorStatus(StrEnum):
|
||||
BLOCKED = "blocked"
|
||||
|
||||
|
||||
class SafetyStage(StrEnum):
|
||||
OBSERVE = "observe"
|
||||
SUGGEST = "suggest"
|
||||
SHADOW = "shadow"
|
||||
PARTIAL = "partial"
|
||||
ACTIVE = "active"
|
||||
|
||||
|
||||
class JobStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class AssignmentCandidate(BaseModel):
|
||||
entity_id: str
|
||||
domain: str
|
||||
@@ -121,6 +136,61 @@ class BehaviorPrediction(BaseModel):
|
||||
execution_reason: str = "Vorhersage wurde noch nicht ausgeführt."
|
||||
|
||||
|
||||
class DecisionFactor(BaseModel):
|
||||
entity_id: str | None = None
|
||||
label: str
|
||||
factor_type: str = Field(max_length=40)
|
||||
state: str | None = None
|
||||
weight: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||
contribution: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
evidence: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SafetyRule(BaseModel):
|
||||
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||
label: str = Field(min_length=1, max_length=160)
|
||||
enabled: bool = True
|
||||
blocking: bool = True
|
||||
reason: str = Field(default="", max_length=300)
|
||||
|
||||
|
||||
def default_safety_rules() -> list[SafetyRule]:
|
||||
return [
|
||||
SafetyRule(
|
||||
rule_id="activation_ready",
|
||||
label="Nur nach Lernfreigabe aktiv schalten",
|
||||
reason="Der Aktor muss genug eindeutiges Verhalten gelernt haben.",
|
||||
),
|
||||
SafetyRule(
|
||||
rule_id="confidence_threshold",
|
||||
label="Mindest-Sicherheit einhalten",
|
||||
reason="Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus.",
|
||||
),
|
||||
SafetyRule(
|
||||
rule_id="cooldown",
|
||||
label="Sicherheits-Cooldown gegen Hin-und-her-Schalten",
|
||||
reason="Gleiche Zielzustände werden nicht zu schnell wiederholt.",
|
||||
),
|
||||
SafetyRule(
|
||||
rule_id="manual_block",
|
||||
label="Manuelle Sperre respektieren",
|
||||
reason="Nutzer können jeden Aktor sofort blockieren.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SafetyProfile(BaseModel):
|
||||
stage: SafetyStage = SafetyStage.SHADOW
|
||||
manual_block: bool = False
|
||||
min_confidence: float = Field(default=0.82, ge=0.0, le=1.0)
|
||||
min_confidence_on: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
min_confidence_off: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
cooldown_seconds: int | None = Field(default=None, ge=0)
|
||||
rules: list[SafetyRule] = Field(default_factory=default_safety_rules)
|
||||
updated_at: datetime | None = None
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ExecutionEvent(BaseModel):
|
||||
target_state: str
|
||||
executed_at: datetime
|
||||
@@ -150,6 +220,16 @@ class BehaviorState(BaseModel):
|
||||
related_automations: list[RelatedAutomation] = Field(default_factory=list)
|
||||
paused_automation_entity_ids: list[str] = Field(default_factory=list)
|
||||
reason: str = "Historische Aktorhandlungen werden analysiert."
|
||||
safety: SafetyProfile = Field(default_factory=SafetyProfile)
|
||||
decision_factors: list[DecisionFactor] = Field(default_factory=list)
|
||||
knowledge: list[str] = Field(default_factory=list)
|
||||
assumptions: list[str] = Field(default_factory=list)
|
||||
uncertainties: list[str] = Field(default_factory=list)
|
||||
safety_blockers: list[str] = Field(default_factory=list)
|
||||
sample_trend: list[int] = Field(default_factory=list)
|
||||
confidence_trend: list[float] = Field(default_factory=list)
|
||||
correct_feedback_count: int = Field(default=0, ge=0)
|
||||
incorrect_feedback_count: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class ActuatorRecord(BaseModel):
|
||||
@@ -176,5 +256,22 @@ class ReconciliationState(BaseModel):
|
||||
last_summary: str = "Noch keine Reconciliation ausgeführt."
|
||||
|
||||
|
||||
class JobQueueItem(BaseModel):
|
||||
job_id: str = Field(min_length=1, max_length=120)
|
||||
kind: str = Field(min_length=1, max_length=40)
|
||||
target: str | None = Field(default=None, max_length=160)
|
||||
trigger: str = Field(default="manual", max_length=40)
|
||||
status: JobStatus = JobStatus.PENDING
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
duration_ms: int | None = Field(default=None, ge=0)
|
||||
error: str | None = Field(default=None, max_length=500)
|
||||
summary: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
class JobQueueState(BaseModel):
|
||||
jobs: list[JobQueueItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
def model_id_for_actuator(actuator_entity_id: str) -> str:
|
||||
return f"actuator.{actuator_entity_id}"
|
||||
|
||||
@@ -8,6 +8,9 @@ from threading import RLock
|
||||
|
||||
from app.actuators.models import (
|
||||
ActuatorRecord,
|
||||
JobQueueItem,
|
||||
JobQueueState,
|
||||
JobStatus,
|
||||
LifecycleStatus,
|
||||
ModelLifecycleState,
|
||||
ReconciliationState,
|
||||
@@ -22,6 +25,7 @@ class ActuatorStore:
|
||||
self._actuators_root.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = RLock()
|
||||
self._reconciliation_state_path = self._root / "reconciliation_state.json"
|
||||
self._job_queue_path = self._root / "job_queue.json"
|
||||
|
||||
def list(self) -> list[ActuatorRecord]:
|
||||
with self._lock:
|
||||
@@ -85,6 +89,75 @@ class ActuatorStore:
|
||||
self._persist_reconciliation_state(state)
|
||||
return state
|
||||
|
||||
def load_job_queue(self) -> JobQueueState:
|
||||
with self._lock:
|
||||
if not self._job_queue_path.exists():
|
||||
return JobQueueState()
|
||||
try:
|
||||
return JobQueueState.model_validate_json(
|
||||
self._job_queue_path.read_text(encoding="utf-8")
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Ungültiger Job-Queue-Status.") from exc
|
||||
|
||||
def start_job(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
trigger: str,
|
||||
target: str | None = None,
|
||||
summary: str = "",
|
||||
) -> JobQueueItem:
|
||||
now = datetime.now(timezone.utc)
|
||||
job = JobQueueItem(
|
||||
job_id=f"{now.strftime('%Y%m%d%H%M%S%f')}-{kind}-{target or 'all'}",
|
||||
kind=kind,
|
||||
target=target,
|
||||
trigger=trigger,
|
||||
status=JobStatus.RUNNING,
|
||||
started_at=now,
|
||||
summary=summary,
|
||||
)
|
||||
with self._lock:
|
||||
queue = self.load_job_queue()
|
||||
queue.jobs = [*queue.jobs, job][-50:]
|
||||
self._persist_job_queue(queue)
|
||||
return job
|
||||
|
||||
def finish_job(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
status: JobStatus,
|
||||
summary: str = "",
|
||||
error: str | None = None,
|
||||
) -> JobQueueItem | None:
|
||||
now = datetime.now(timezone.utc)
|
||||
with self._lock:
|
||||
queue = self.load_job_queue()
|
||||
updated_job: JobQueueItem | None = None
|
||||
jobs: list[JobQueueItem] = []
|
||||
for job in queue.jobs:
|
||||
if job.job_id != job_id:
|
||||
jobs.append(job)
|
||||
continue
|
||||
duration_ms = None
|
||||
if job.started_at is not None:
|
||||
duration_ms = max(0, int((now - job.started_at).total_seconds() * 1000))
|
||||
updated_job = job.model_copy(
|
||||
update={
|
||||
"status": status,
|
||||
"completed_at": now,
|
||||
"duration_ms": duration_ms,
|
||||
"summary": summary or job.summary,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
jobs.append(updated_job)
|
||||
queue.jobs = jobs[-50:]
|
||||
self._persist_job_queue(queue)
|
||||
return updated_job
|
||||
|
||||
def _target(self, actuator_entity_id: str) -> Path:
|
||||
if "." not in actuator_entity_id:
|
||||
raise ValueError("Ungültige actuator_entity_id.")
|
||||
@@ -108,6 +181,14 @@ class ActuatorStore:
|
||||
)
|
||||
os.replace(temporary, self._reconciliation_state_path)
|
||||
|
||||
def _persist_job_queue(self, state: JobQueueState) -> None:
|
||||
temporary = self._job_queue_path.with_suffix(".json.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(state.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, self._job_queue_path)
|
||||
|
||||
@staticmethod
|
||||
def _load(path: Path) -> ActuatorRecord:
|
||||
try:
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.models import ActuatorRecord, ReconciliationState, SensorWeightGroup
|
||||
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine
|
||||
from app.config import Settings
|
||||
@@ -55,6 +56,10 @@ class FeedbackRequest(BaseModel):
|
||||
expected_state: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class SafetyProfileRequest(BaseModel):
|
||||
safety: SafetyProfile
|
||||
|
||||
|
||||
class ActuatorSuggestion(BaseModel):
|
||||
entity_id: str
|
||||
domain: str
|
||||
@@ -112,6 +117,7 @@ class DashboardOverview(BaseModel):
|
||||
cache: EntityCacheStatus
|
||||
actuators: list[ActuatorSummary]
|
||||
discovery_groups: list[DashboardDiscoveryGroup]
|
||||
jobs: JobQueueState = Field(default_factory=JobQueueState)
|
||||
|
||||
|
||||
@router.get("/discovery", response_model=list[HaEntitySummary])
|
||||
@@ -124,8 +130,19 @@ def discover_actuators(
|
||||
if cached_entities:
|
||||
entities = {entity.entity_id: entity for entity in cached_entities}
|
||||
else:
|
||||
fresh_entities = list(ha_reader.read_entities())
|
||||
_save_cached_entities(request, fresh_entities)
|
||||
job = _start_job(
|
||||
request,
|
||||
kind="discovery",
|
||||
trigger="manual" if refresh else "cache-miss",
|
||||
summary="Home-Assistant-Entities werden gelesen und klassifiziert.",
|
||||
)
|
||||
try:
|
||||
fresh_entities = list(ha_reader.read_entities())
|
||||
_save_cached_entities(request, fresh_entities)
|
||||
except Exception as exc:
|
||||
_finish_job(job, request, status=JobStatus.FAILED, summary="Discovery fehlgeschlagen.", error=str(exc))
|
||||
raise
|
||||
_finish_job(job, request, status=JobStatus.COMPLETED, summary=f"{len(fresh_entities)} Entities klassifiziert.")
|
||||
entities = {entity.entity_id: entity for entity in fresh_entities}
|
||||
discovered = discover_entities(list(entities.values()))
|
||||
actuator_ids = _deduplicate_actuator_ids(
|
||||
@@ -278,6 +295,12 @@ def dashboard_overview(request: Request) -> DashboardOverview:
|
||||
reconciliation = _reconciliation_state_or_default(request)
|
||||
ws_status = getattr(request.app.state, "ws_status", None)
|
||||
actuators = list_configured_summary(request)
|
||||
store = getattr(request.app.state, "actuator_store", None)
|
||||
jobs = (
|
||||
store.load_job_queue()
|
||||
if isinstance(store, ActuatorStore)
|
||||
else JobQueueState()
|
||||
)
|
||||
return DashboardOverview(
|
||||
system=DashboardSystemStatus(
|
||||
websocket_status=getattr(ws_status, "status", "unavailable"),
|
||||
@@ -298,6 +321,7 @@ def dashboard_overview(request: Request) -> DashboardOverview:
|
||||
),
|
||||
actuators=actuators,
|
||||
discovery_groups=cached_groups,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
|
||||
@@ -372,6 +396,18 @@ def record_feedback(
|
||||
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,
|
||||
payload: SafetyProfileRequest,
|
||||
request: Request,
|
||||
) -> ActuatorRecord:
|
||||
try:
|
||||
return _behavior(request).set_safety_profile(actuator_entity_id, profile=payload.safety)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
|
||||
def set_activation(
|
||||
actuator_entity_id: str,
|
||||
@@ -441,11 +477,27 @@ def refresh_related_automations(
|
||||
actuator_entity_id: str,
|
||||
request: Request,
|
||||
) -> ActuatorRecord:
|
||||
job = _start_job(
|
||||
request,
|
||||
kind="automation_refresh",
|
||||
trigger="manual",
|
||||
target=actuator_entity_id,
|
||||
summary="Passende HA-Automationen werden gesucht.",
|
||||
)
|
||||
try:
|
||||
return _behavior(request).refresh_related_automations(actuator_entity_id)
|
||||
record = _behavior(request).refresh_related_automations(actuator_entity_id)
|
||||
_finish_job(
|
||||
job,
|
||||
request,
|
||||
status=JobStatus.COMPLETED,
|
||||
summary=f"{len(record.behavior.related_automations)} Automationen gefunden.",
|
||||
)
|
||||
return record
|
||||
except KeyError as exc:
|
||||
_finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(exc))
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (ValueError, HaClientError) as exc:
|
||||
_finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(exc))
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@@ -486,12 +538,85 @@ def run_reconciliation(
|
||||
request: Request,
|
||||
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
|
||||
) -> ReconciliationState:
|
||||
state = _service(request).reconcile_all(trigger=trigger)
|
||||
_behavior(request).train_all()
|
||||
_behavior(request).evaluate_all()
|
||||
reconciliation_job = _start_job(
|
||||
request,
|
||||
kind="reconciliation",
|
||||
trigger=trigger,
|
||||
summary="Kontext, Zuordnung und Modelle werden abgeglichen.",
|
||||
)
|
||||
training_job: JobQueueItem | None = None
|
||||
evaluation_job: JobQueueItem | None = None
|
||||
try:
|
||||
state = _service(request).reconcile_all(trigger=trigger)
|
||||
_finish_job(reconciliation_job, request, status=JobStatus.COMPLETED, summary=state.last_summary)
|
||||
reconciliation_job = None
|
||||
training_job = _start_job(
|
||||
request,
|
||||
kind="training",
|
||||
trigger=trigger,
|
||||
summary="Gelernte Aktorhandlungen werden aktualisiert.",
|
||||
)
|
||||
_behavior(request).train_all()
|
||||
_finish_job(training_job, request, status=JobStatus.COMPLETED, summary="Training abgeschlossen.")
|
||||
training_job = None
|
||||
evaluation_job = _start_job(
|
||||
request,
|
||||
kind="evaluation",
|
||||
trigger=trigger,
|
||||
summary="Aktuelle Vorhersagen werden neu berechnet.",
|
||||
)
|
||||
_behavior(request).evaluate_all()
|
||||
_finish_job(evaluation_job, request, status=JobStatus.COMPLETED, summary="Evaluation abgeschlossen.")
|
||||
evaluation_job = None
|
||||
except Exception as exc:
|
||||
for job in [reconciliation_job, training_job, evaluation_job]:
|
||||
if isinstance(job, JobQueueItem) and job.status is JobStatus.RUNNING:
|
||||
_finish_job(job, request, status=JobStatus.FAILED, summary="Job fehlgeschlagen.", error=str(exc))
|
||||
raise
|
||||
return state
|
||||
|
||||
|
||||
@router.get("/job-queue/state", response_model=JobQueueState)
|
||||
def get_job_queue(request: Request) -> JobQueueState:
|
||||
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 store.load_job_queue()
|
||||
|
||||
|
||||
def _start_job(
|
||||
request: Request,
|
||||
*,
|
||||
kind: str,
|
||||
trigger: str,
|
||||
target: str | None = None,
|
||||
summary: str = "",
|
||||
) -> JobQueueItem | None:
|
||||
store = getattr(request.app.state, "actuator_store", None)
|
||||
if not isinstance(store, ActuatorStore):
|
||||
return None
|
||||
return store.start_job(kind=kind, trigger=trigger, target=target, summary=summary)
|
||||
|
||||
|
||||
def _finish_job(
|
||||
job: JobQueueItem | None,
|
||||
request: Request,
|
||||
*,
|
||||
status: JobStatus,
|
||||
summary: str,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
if job is None:
|
||||
return
|
||||
store = getattr(request.app.state, "actuator_store", None)
|
||||
if not isinstance(store, ActuatorStore):
|
||||
return
|
||||
store.finish_job(job.job_id, status=status, summary=summary, error=error)
|
||||
|
||||
|
||||
def _service(request: Request) -> ActuatorReconciliationService:
|
||||
service = getattr(request.app.state, "actuator_service", None)
|
||||
if not isinstance(service, ActuatorReconciliationService):
|
||||
|
||||
@@ -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],
|
||||
*,
|
||||
|
||||
@@ -105,7 +105,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.0.5",
|
||||
version="1.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = load_settings()
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
.bad { color: var(--bad); }
|
||||
label { display:block; margin:9px 0 4px; color:#c3d2df; font-weight:700; }
|
||||
select,input,button { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:10px; background:#111821; color:#fff; font:inherit; min-width:0; }
|
||||
input[type="checkbox"] { width:auto; min-width:0; vertical-align:middle; margin-right:8px; }
|
||||
select[multiple] { min-height:150px; }
|
||||
button { min-height:42px; margin-top:10px; background:var(--accent); color:#211204; border:0; font-weight:850; cursor:pointer; }
|
||||
button.secondary { background:var(--complement-soft); color:#dff6ff; border:1px solid #22607c; }
|
||||
@@ -96,6 +97,9 @@
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
|
||||
.metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
|
||||
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
|
||||
.decision-list { display:grid; gap:8px; margin:10px 0; }
|
||||
.decision-row { background:#121922; border:1px solid var(--border); border-radius:8px; padding:9px; min-width:0; overflow-wrap:anywhere; }
|
||||
.decision-row header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.actions button { flex:1 1 180px; margin-top:0; }
|
||||
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
||||
@@ -232,6 +236,7 @@
|
||||
<div id="status">Prüfung läuft ...</div>
|
||||
<div class="chips" id="status-chips"></div>
|
||||
<div id="dashboard-stats" class="metric-grid"></div>
|
||||
<div id="job-queue" class="decision-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="guide-panel" id="guide">
|
||||
@@ -486,6 +491,7 @@ function renderDashboardStatus(dashboard) {
|
||||
const status = document.getElementById("status");
|
||||
const chips = document.getElementById("status-chips");
|
||||
const stats = document.getElementById("dashboard-stats");
|
||||
const jobsBox = document.getElementById("job-queue");
|
||||
const system = dashboard.system || {};
|
||||
const cache = dashboard.cache || {};
|
||||
const actuators = dashboard.actuators || [];
|
||||
@@ -498,6 +504,8 @@ function renderDashboardStatus(dashboard) {
|
||||
).length;
|
||||
const trainedCount = actuators.filter(record => record.behavior_status === "trained").length;
|
||||
const sampleTotal = actuators.reduce((sum, record) => sum + Number(record.sample_count || 0), 0);
|
||||
const jobs = dashboard.jobs?.jobs || [];
|
||||
const runningJobs = jobs.filter(job => job.status === "running").length;
|
||||
const cacheLabel = cache.available
|
||||
? `Cache aktuell mit ${cache.entity_count} Entities`
|
||||
: "Cache wird nach Discovery aufgebaut";
|
||||
@@ -513,6 +521,7 @@ function renderDashboardStatus(dashboard) {
|
||||
`<span class="chip">Aktoren: ${escapeHtml(system.configured_actuators ?? 0)}</span>`,
|
||||
`<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`,
|
||||
`<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`,
|
||||
`<span class="chip">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`,
|
||||
].join("");
|
||||
stats.innerHTML = [
|
||||
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`,
|
||||
@@ -523,6 +532,21 @@ function renderDashboardStatus(dashboard) {
|
||||
`<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`,
|
||||
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
|
||||
].join("");
|
||||
jobsBox.innerHTML = jobs.length ? `
|
||||
<h3>Job-Queue</h3>
|
||||
${jobs.slice(-6).reverse().map(job => `
|
||||
<div class="decision-row">
|
||||
<header>
|
||||
<strong>${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong>
|
||||
<span class="chip">${escapeHtml(job.status)}</span>
|
||||
</header>
|
||||
<p class="muted">${escapeHtml(job.summary || "Keine Zusammenfassung")}</p>
|
||||
<p class="muted">Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p>
|
||||
${job.error ? `<p class="bad">${escapeHtml(job.error)}</p>` : ""}
|
||||
${job.status === "failed" ? "<p class='warn'>Retry: Aktion im Dashboard erneut starten; der nächste Lauf schreibt einen neuen Queue-Eintrag.</p>" : ""}
|
||||
</div>
|
||||
`).join("")}
|
||||
` : "";
|
||||
}
|
||||
|
||||
async function loadSummaryData() {
|
||||
@@ -873,6 +897,71 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
`).join("")}</ul>`
|
||||
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
|
||||
const prediction = record.behavior.prediction;
|
||||
const safety = record.behavior.safety || {};
|
||||
const blockers = record.behavior.safety_blockers || [];
|
||||
const decisionFactors = record.behavior.decision_factors || [];
|
||||
const knowledge = record.behavior.knowledge || [];
|
||||
const assumptions = record.behavior.assumptions || [];
|
||||
const uncertainties = record.behavior.uncertainties || [];
|
||||
const safetyControls = `
|
||||
<details class="manual-context" open>
|
||||
<summary>Sicherheit und manuelles Gegensteuern</summary>
|
||||
<div class="inline-controls">
|
||||
<div>
|
||||
<label for="safety-stage">Freigabestufe</label>
|
||||
<select id="safety-stage">
|
||||
${["observe", "suggest", "shadow", "partial", "active"].map(stage => `
|
||||
<option value="${stage}" ${safety.stage === stage ? "selected" : ""}>${stage}</option>
|
||||
`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="safety-confidence">Mindest-Sicherheit in %</label>
|
||||
<input id="safety-confidence" type="number" min="0" max="100" step="1" value="${Math.round((safety.min_confidence ?? 0.82) * 100)}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="safety-cooldown">Cooldown Sekunden</label>
|
||||
<input id="safety-cooldown" type="number" min="0" step="10" value="${safety.cooldown_seconds ?? ""}" placeholder="Standard">
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
<input id="safety-manual-block" type="checkbox" ${safety.manual_block ? "checked" : ""}>
|
||||
Manuelle Sicherheitssperre aktiv
|
||||
</label>
|
||||
${blockers.length ? `<p class="warn">Aktuelle Blocker: ${blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.</p>"}
|
||||
<button class="secondary" onclick="saveSafetyProfile('${escapeHtml(record.actuator_entity_id)}')">Sicherheitsprofil speichern</button>
|
||||
</details>
|
||||
`;
|
||||
const decisionArchive = `
|
||||
<details class="manual-context" open>
|
||||
<summary>Entscheidungsakte</summary>
|
||||
<div class="grid-two">
|
||||
<div>
|
||||
<h3>Wissen</h3>
|
||||
<ul>${knowledge.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine gesicherten Punkte gespeichert.</li>"}</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Annahmen</h3>
|
||||
<ul>${assumptions.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Annahmen gespeichert.</li>"}</ul>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Unsicherheit</h3>
|
||||
<ul>${uncertainties.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Unsicherheit gespeichert.</li>"}</ul>
|
||||
<h3>Beitragsfaktoren</h3>
|
||||
<div class="decision-list">
|
||||
${decisionFactors.length ? decisionFactors.map(factor => `
|
||||
<div class="decision-row">
|
||||
<header>
|
||||
<strong>${escapeHtml(factor.label)}</strong>
|
||||
<span class="chip">${Math.round((factor.contribution || 0) * 100)} % Beitrag</span>
|
||||
</header>
|
||||
<p class="muted">${escapeHtml(factor.entity_id || factor.factor_type)} · Zustand: ${escapeHtml(factor.state || "offen")} · Gewicht: ${Math.round((factor.weight || 0) * 100)} %</p>
|
||||
<p>${(factor.evidence || []).map(escapeHtml).join(" ")}</p>
|
||||
</div>
|
||||
`).join("") : "<p class='muted'>Noch keine aktuelle Entscheidungsfaktoren berechnet.</p>"}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
const learnedAutomationActions = record.behavior.patterns.filter(
|
||||
pattern => pattern.source === "automation",
|
||||
).length;
|
||||
@@ -982,6 +1071,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', true)">Vorhersage korrekt</button>
|
||||
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button>
|
||||
</div>
|
||||
${safetyControls}
|
||||
${decisionArchive}
|
||||
<h3>Passende Home-Assistant-Automationen</h3>
|
||||
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
|
||||
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
|
||||
@@ -1184,6 +1275,36 @@ async function sendFeedback(actuatorId, correct) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSafetyProfile(actuatorId) {
|
||||
const confidence = Number(document.getElementById("safety-confidence")?.value || 82);
|
||||
const cooldownRaw = document.getElementById("safety-cooldown")?.value || "";
|
||||
const cooldown = cooldownRaw === "" ? null : Math.max(0, Number(cooldownRaw));
|
||||
const profile = {
|
||||
stage: document.getElementById("safety-stage")?.value || "shadow",
|
||||
manual_block: Boolean(document.getElementById("safety-manual-block")?.checked),
|
||||
min_confidence: Math.max(0, Math.min(100, Number.isFinite(confidence) ? confidence : 82)) / 100,
|
||||
cooldown_seconds: Number.isFinite(cooldown) ? cooldown : null,
|
||||
rules: [
|
||||
{rule_id: "activation_ready", label: "Nur nach Lernfreigabe aktiv schalten", enabled: true, blocking: true, reason: "Der Aktor muss genug eindeutiges Verhalten gelernt haben."},
|
||||
{rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus."},
|
||||
{rule_id: "cooldown", label: "Sicherheits-Cooldown gegen Hin-und-her-Schalten", enabled: true, blocking: true, reason: "Gleiche Zielzustände werden nicht zu schnell wiederholt."},
|
||||
{rule_id: "manual_block", label: "Manuelle Sperre respektieren", enabled: true, blocking: true, reason: "Nutzer können jeden Aktor sofort blockieren."},
|
||||
],
|
||||
note: "Sicherheitsprofil im Dashboard gespeichert",
|
||||
};
|
||||
try {
|
||||
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/safety`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({safety: profile}),
|
||||
});
|
||||
invalidateDashboardCache();
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(actuatorId, "Sicherheitsprofil gespeichert.");
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
|
||||
const question = active
|
||||
? pauseMatchingAutomations
|
||||
|
||||
Reference in New Issue
Block a user