diff --git a/CHANGELOG.md b/CHANGELOG.md index c5bf971..a9c2141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.1.0 - 2026-06-17 +- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert: + Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/ + Unsicherheiten und Beitragsfaktoren pro Aktor. +- Lokales Safety-Profil pro Aktor eingefuehrt: manuelle Sperre, + Freigabestufe, Mindest-Confidence und optionaler Cooldown werden vor + autonomem Schalten ausgewertet. +- Sofort-Schaltpfad bleibt schnell: Safety prueft nur lokale Daten; der + Home-Assistant-Serviceaufruf wird nicht durch Discovery, Training oder + Statistik blockiert. +- Sichtbare Job-Queue fuer Discovery, Reconciliation, Training, Evaluation + und Automation-Refresh mit Status, Dauer, Fehler und Zusammenfassung. +- Entscheidungsstatistik erweitert: Sensor-/Kontextfaktoren, aktive + Gewichtungen, Sample-/Confidence-Trends und Feedbackzaehler werden + persistiert. + ## 1.0.5 - 2026-06-17 - Lange Friendly Names, Entity-IDs, Chips, Tabellenwerte und Metriken brechen im Dashboard responsiv um und laufen nicht mehr aus Karten oder Boxen. diff --git a/README.md b/README.md index 4dbbd86..70a3846 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ nach einer ausdrücklichen Freigabe ausführen. [`docs/V1_0_0_OPERATING_GUIDE.md`](docs/V1_0_0_OPERATING_GUIDE.md) - Version 1.0.x Abnahme und offene Punkte: [`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md) +- Version 1.1.0 Safety, Transparenz und Job-Queue: + [`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) ## Reifegrad diff --git a/addon/config.yaml b/addon/config.yaml index f545320..798567b 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.0.5" +version: "1.1.0" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/actuators/models.py b/app/actuators/models.py index a4f4dc8..94d09e4 100644 --- a/app/actuators/models.py +++ b/app/actuators/models.py @@ -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}" diff --git a/app/actuators/store.py b/app/actuators/store.py index 1b771d1..c37fa35 100644 --- a/app/actuators/store.py +++ b/app/actuators/store.py @@ -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: diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 79f3c94..4454a8c 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -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): diff --git a/app/behavior/engine.py b/app/behavior/engine.py index 534ccb5..332e101 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -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], *, diff --git a/app/main.py b/app/main.py index 1680a39..e928229 100644 --- a/app/main.py +++ b/app/main.py @@ -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() diff --git a/app/static/index.html b/app/static/index.html index d84db09..3e71763 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -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 @@
Prüfung läuft ...
+
@@ -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) { `Aktoren: ${escapeHtml(system.configured_actuators ?? 0)}`, `Lernbereit: ${escapeHtml(system.trained_models ?? 0)}`, `Prüfen: ${escapeHtml(system.review_required ?? 0)}`, + `Jobs aktiv: ${escapeHtml(runningJobs)}`, ].join(""); stats.innerHTML = [ `
Geladene Startdaten${escapeHtml(actuators.length)} Geräte
`, @@ -523,6 +532,21 @@ function renderDashboardStatus(dashboard) { `
Discovery-Gruppen${escapeHtml(discoveryGroups.length)} Kategorien
`, `
Cache-Zeitpunkt${escapeHtml(cache.updated_at || "noch offen")}
`, ].join(""); + jobsBox.innerHTML = jobs.length ? ` +

Job-Queue

+ ${jobs.slice(-6).reverse().map(job => ` +
+
+ ${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""} + ${escapeHtml(job.status)} +
+

${escapeHtml(job.summary || "Keine Zusammenfassung")}

+

Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}

+ ${job.error ? `

${escapeHtml(job.error)}

` : ""} + ${job.status === "failed" ? "

Retry: Aktion im Dashboard erneut starten; der nächste Lauf schreibt einen neuen Queue-Eintrag.

" : ""} +
+ `).join("")} + ` : ""; } async function loadSummaryData() { @@ -873,6 +897,71 @@ async function showActuator(actuatorId, evaluationMessage = "") { `).join("")}` : "

Noch keine Kontext-Entity ausgewählt.

"; 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 = ` +
+ Sicherheit und manuelles Gegensteuern +
+
+ + +
+
+ + +
+
+ + +
+
+ + ${blockers.length ? `

Aktuelle Blocker: ${blockers.map(escapeHtml).join(" ")}

` : "

Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.

"} + +
+ `; + const decisionArchive = ` +
+ Entscheidungsakte +
+
+

Wissen

+
    ${knowledge.map(item => `
  • ${escapeHtml(item)}
  • `).join("") || "
  • Keine gesicherten Punkte gespeichert.
  • "}
+
+
+

Annahmen

+
    ${assumptions.map(item => `
  • ${escapeHtml(item)}
  • `).join("") || "
  • Keine Annahmen gespeichert.
  • "}
+
+
+

Unsicherheit

+ +

Beitragsfaktoren

+
+ ${decisionFactors.length ? decisionFactors.map(factor => ` +
+
+ ${escapeHtml(factor.label)} + ${Math.round((factor.contribution || 0) * 100)} % Beitrag +
+

${escapeHtml(factor.entity_id || factor.factor_type)} · Zustand: ${escapeHtml(factor.state || "offen")} · Gewicht: ${Math.round((factor.weight || 0) * 100)} %

+

${(factor.evidence || []).map(escapeHtml).join(" ")}

+
+ `).join("") : "

Noch keine aktuelle Entscheidungsfaktoren berechnet.

"} +
+
+ `; const learnedAutomationActions = record.behavior.patterns.filter( pattern => pattern.source === "automation", ).length; @@ -982,6 +1071,8 @@ async function showActuator(actuatorId, evaluationMessage = "") { + ${safetyControls} + ${decisionArchive}

Passende Home-Assistant-Automationen

Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.

@@ -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 diff --git a/docs/V1_1_0_OPERATING_GUIDE.md b/docs/V1_1_0_OPERATING_GUIDE.md new file mode 100644 index 0000000..2736fc9 --- /dev/null +++ b/docs/V1_1_0_OPERATING_GUIDE.md @@ -0,0 +1,72 @@ +# SillyHome Next v1.1.0 Operating Guide + +## Ziel + +v1.1.0 macht das Dashboard zur Zentrale fuer Visualisierung, Einrichtung, +Sicherheit und manuelles Gegensteuern. Autonomes Schalten bleibt ein kurzer +lokaler Pfad: Vorhersage und Safety-Profil werden aus bereits vorhandenen Daten +bewertet, danach folgt direkt der Home-Assistant-Serviceaufruf. + +## Sicherheitsmodell + +Jeder Aktor hat ein Safety-Profil: + +- `stage`: Beobachten, Vorschlagen, Shadow, Teilaktiv oder Aktiv. +- `manual_block`: harte manuelle Sperre. +- `min_confidence`: Mindest-Sicherheit fuer autonomes Schalten. +- `cooldown_seconds`: optionaler Aktor-Cooldown gegen schnelles Hin-und-her. +- Safety-Regeln: Freigabe, Confidence, Cooldown und manuelle Sperre. + +Ein Aktor schaltet nur, wenn alle lokalen Safety-Regeln frei sind, der +Behavior-Modus aktiv ist, die Freigabe bereit ist, die Confidence passt, der +Zielzustand noch nicht erreicht ist und der Cooldown abgelaufen ist. + +## Transparenz + +Die Aktor-Detailansicht trennt: + +- Wissen: belegte Fakten aus Historie, Zuordnung und Automationen. +- Annahmen: heuristische Schluesse wie Zeit-/Kontext-Aehnlichkeit. +- Unsicherheiten: geringe Datenmenge, unklare Quellen, Review-Bedarf oder + negatives Feedback. +- Beitragsfaktoren: Sensoren, Kontextsignale, aktive Gewichtung und Beitrag. +- Safety-Blocker: Gruende, warum nicht geschaltet wird. + +## Job-Queue + +Das Dashboard zeigt die letzten Jobs mit Status, Dauer, Fehler und +Zusammenfassung. Sichtbar sind: + +- Discovery +- Reconciliation +- Training +- Evaluation +- Automation-Refresh + +Die Queue ist persistent in `job_queue.json` und dient als Betriebsanzeige. Sie +blockiert nicht den Startpfad und nicht den Schaltpfad. + +## Manuelles Gegensteuern + +Im Dashboard koennen pro Aktor gesetzt werden: + +- manuelle Sicherheitssperre +- Freigabestufe +- Mindest-Confidence +- optionaler Cooldown +- Sensor-Gewichtungen und Gruppen-Gewichtungen +- Kontextauswahl +- Feedback: Vorhersage korrekt/falsch +- HA-Automationen pausieren/fortsetzen + +## Qualitaetspruefung + +Vor Release: + +```bash +.venv/bin/pytest -q +.venv/bin/ruff check . +.venv/bin/mypy app backend tests +git diff --check +node --check /tmp/sillyhome-dashboard.js +``` diff --git a/pyproject.toml b/pyproject.toml index 78500c9..ffbfff2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.0.5" +version = "1.1.0" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index 2c2668f..601b106 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -30,6 +30,7 @@ class FakeHaReader(HaReader): self._entities = entities self._history = history self.read_entities_calls = 0 + self.service_calls: list[tuple[str, str, dict[str, object]]] = [] def read_entities(self) -> list[HaEntitySummary]: self.read_entities_calls += 1 @@ -86,6 +87,7 @@ class FakeHaReader(HaReader): service: str, service_data: dict[str, object], ) -> list[object]: + self.service_calls.append((domain, service, service_data)) return [] def find_automations_for_entity( @@ -264,6 +266,40 @@ def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> No assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75 +def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) + + response = client.post( + "/v1/actuators/light.abstellkammer/safety", + json={ + "safety": { + "stage": "shadow", + "manual_block": True, + "min_confidence": 0.9, + "cooldown_seconds": 120, + "rules": [ + { + "rule_id": "manual_block", + "label": "Manuelle Sperre respektieren", + "enabled": True, + "blocking": True, + "reason": "Test", + } + ], + "note": "Test", + } + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["behavior"]["safety"]["manual_block"] is True + assert payload["behavior"]["safety"]["min_confidence"] == 0.9 + assert payload["behavior"]["safety"]["cooldown_seconds"] == 120 + + def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None: with TestClient(app) as client: _install_service(tmp_path) @@ -298,6 +334,26 @@ def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> N assert payload["cache"]["entity_count"] == 4 assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht" assert payload["discovery_groups"] + assert payload["jobs"]["jobs"][-1]["kind"] == "discovery" + + +def test_reconciliation_run_records_visible_job_queue(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) + + response = client.post("/v1/actuators/reconciliation/run") + jobs = client.get("/v1/actuators/job-queue/state") + + assert response.status_code == 200 + assert jobs.status_code == 200 + payload = jobs.json() + assert [job["kind"] for job in payload["jobs"][-3:]] == [ + "reconciliation", + "training", + "evaluation", + ] + assert payload["jobs"][-1]["status"] == "completed" def test_dashboard_start_path_stays_within_five_second_budget(tmp_path: Path) -> None: