From 7ad97320a2350d631e5632f4722987fd9a7b884c Mon Sep 17 00:00:00 2001 From: Otto Date: Sun, 14 Jun 2026 15:58:05 +0200 Subject: [PATCH] BEHAVIOR-004: trust HA automation actions --- CHANGELOG.md | 7 +++++++ README.md | 5 +++-- addon/config.yaml | 2 +- app/behavior/engine.py | 14 +++++++------- app/static/index.html | 12 ++++++------ docs/ml_training.md | 14 +++++++------- tests/behavior/test_engine.py | 33 ++++++++++++++++++++++++--------- tests/test_dashboard.py | 7 +++++-- 8 files changed, 60 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad89a2b..741ab25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.6.2 - 2026-06-14 +- Eindeutig im Home-Assistant-Logbuch erkannte Automationen und Scripts zählen für + Lernen und Freigabe gleichwertig wie manuelle Bedienungen +- Automationsmuster erhalten dieselbe Modellgewichtung wie manuelle Handlungen +- Oberfläche zeigt die gemeinsame Zahl als `eindeutig geregelt`; eine + ausdrückliche Aktivierung pro Aktor bleibt weiterhin erforderlich + ## 0.6.1 - 2026-06-14 - Manuelle Prüfung als `Aktuelle Situation auswerten` eindeutig von Simulation oder Aktorschaltung abgegrenzt diff --git a/README.md b/README.md index 585e139..9dc6a8c 100644 --- a/README.md +++ b/README.md @@ -109,8 +109,9 @@ Lernentscheidungen erfolgen automatisch. System das lokale Modell automatisch. 5. Vorhersagen laufen zunächst ausschließlich im Shadow-Modus. 6. Erst nach ausdrücklicher Freigabe pro Aktor werden hochkonfidente, - erlaubte Zustände geschaltet. Eigene Schaltungen und erkannte - HA-Automationen werden nicht als Nutzerhandlungen zurückgelernt. + erlaubte Zustände geschaltet. Eindeutig im HA-Logbuch erkannte Automationen + und Scripts zählen dabei gleichwertig wie manuelle Bedienungen. Eigene + Schaltungen von SillyHome werden nicht zurückgelernt. Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups** eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte diff --git a/addon/config.yaml b/addon/config.yaml index 2e11fbd..68a07d6 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.6.1" +version: "0.6.2" 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/behavior/engine.py b/app/behavior/engine.py index 793a200..c37f784 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -124,7 +124,9 @@ class BehaviorEngine: logbook=logbook, own_executions=record.behavior.execution_events, ) - high_confidence = sum(1 for pattern in patterns if pattern.source == "user") + trusted_actions = sum( + 1 for pattern in patterns if pattern.source in {"user", "automation"} + ) status = ( BehaviorStatus.TRAINED if len(patterns) >= self._settings.min_behavior_actions @@ -142,7 +144,7 @@ class BehaviorEngine: update={ "status": status, "sample_count": len(patterns), - "high_confidence_sample_count": high_confidence, + "high_confidence_sample_count": trusted_actions, "patterns": patterns[-_MAX_PATTERNS:], "last_trained_at": now, "reason": reason, @@ -296,8 +298,8 @@ class BehaviorEngine: < self._settings.min_behavior_actions ): raise ValueError( - "Für die Freigabe fehlen noch eindeutig dir zugeordnete Handlungen. " - "Bediene den Aktor einige Male über Home Assistant." + "Für die Freigabe fehlen noch eindeutig zugeordnete manuelle " + "oder automatisierte Handlungen." ) mode = BehaviorMode.ACTIVE approved_at = now @@ -338,8 +340,6 @@ class BehaviorEngine: context_ids, point.timestamp, ) - if source == "automation" and trigger is None: - continue contexts = { entity_id: state for entity_id in context_ids @@ -520,7 +520,7 @@ def _action_source( if nearest.context_user_id: return "user", 1.0 if nearest.context_domain in _AUTOMATION_CONTEXT_DOMAINS: - return "automation", 0.1 + return "automation", 1.0 return "physical_or_unknown", 0.7 diff --git a/app/static/index.html b/app/static/index.html index 3245e70..ac3ec03 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -239,20 +239,20 @@ async function showActuator(actuatorId, evaluationMessage = "") { .map(candidate => `
  • ${escapeHtml(candidate.friendly_name || candidate.entity_id)}: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}
  • `) .join(""); const prediction = record.behavior.prediction; - const requiredUserActions = 3; + const requiredTrustedActions = 3; const learnedAutomationActions = record.behavior.patterns.filter( pattern => pattern.source === "automation", ).length; - const missingUserActions = Math.max( + const missingTrustedActions = Math.max( 0, - requiredUserActions - record.behavior.high_confidence_sample_count, + requiredTrustedActions - record.behavior.high_confidence_sample_count, ); const activationButton = record.behavior.mode === "active" ? `` - : record.behavior.status === "trained" && missingUserActions === 0 + : record.behavior.status === "trained" && missingTrustedActions === 0 ? `` : record.behavior.status === "trained" - ? `

    Freigabe noch gesperrt: ${missingUserActions} eindeutig manuelle Bedienung${missingUserActions === 1 ? "" : "en"} fehlen. Bediene das Licht dafür direkt über Home Assistant.

    ` + ? `

    Freigabe noch gesperrt: ${missingTrustedActions} eindeutig zugeordnete manuelle oder automatisierte Handlung${missingTrustedActions === 1 ? "" : "en"} fehlen.

    ` : "

    Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.

    "; box.innerHTML = `
    @@ -268,7 +268,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {

    Lernfortschritt

    Betriebsart: ${escapeHtml(behaviorLabel(record))}

    Gelernte Handlungen: ${record.behavior.sample_count}

    -

    Davon eindeutig Benutzer: ${record.behavior.high_confidence_sample_count}

    +

    Davon eindeutig geregelt: ${record.behavior.high_confidence_sample_count}

    Davon erkannte HA-Automationen: ${learnedAutomationActions}

    Letztes Training: ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}

    Was noch passiert: ${escapeHtml(record.behavior.reason)}

    diff --git a/docs/ml_training.md b/docs/ml_training.md index 7b07282..b57fc31 100644 --- a/docs/ml_training.md +++ b/docs/ml_training.md @@ -12,10 +12,10 @@ Für jeden Aktor lädt SillyHome Next: - automatisch zugeordnete Mess- und Kontext-Entities - deren Zustand zum Zeitpunkt der Handlung -Eindeutig einem Home-Assistant-Benutzer zugeordnete Handlungen erhalten das -höchste Gewicht. Erkannte Automations- und Script-Aktionen werden verworfen. -Physische oder nicht eindeutig zuordenbare Bedienungen dürfen das -Shadow-Modell ergänzen, reichen allein aber nicht zur Aktivierung. +Eindeutig einem Home-Assistant-Benutzer zugeordnete Handlungen und im Logbuch +erkannte Automations- oder Script-Aktionen erhalten das höchste Gewicht. +Physische oder nicht eindeutig zuordenbare Bedienungen dürfen das Shadow-Modell +ergänzen, reichen allein aber nicht zur Aktivierung. ## Modell @@ -36,8 +36,8 @@ Kontext. Mehrere passende historische Handlungen erhöhen die Confidence. 2. `shadow`: Modell ist trainiert; Vorhersagen werden angezeigt, aber nicht ausgeführt. 3. `active`: Nutzer hat den Aktor ausdrücklich freigegeben. -Die Aktivierung verlangt genügend eindeutig einem Benutzer zugeordnete -Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains: +Die Aktivierung verlangt genügend eindeutig zugeordnete manuelle oder +automatisierte Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains: `light`, `switch`, `fan`, `humidifier` und `cover`. ## Schutzmechanismen @@ -48,4 +48,4 @@ Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains: - keine Ausführung bei bereits erreichtem Zielzustand - keine Ausführung unbekannter Zustände oder riskanter Domains - eigene Schaltungen werden beim nächsten Training herausgefiltert -- bekannte Automation-/Script-Aktionen werden nicht als Nutzerverhalten gelernt +- Automation-/Script-Aktionen zählen nur bei eindeutiger Herkunft im HA-Logbuch diff --git a/tests/behavior/test_engine.py b/tests/behavior/test_engine.py index 5ae7b32..0d82415 100644 --- a/tests/behavior/test_engine.py +++ b/tests/behavior/test_engine.py @@ -161,8 +161,8 @@ def test_engine_trains_predicts_in_shadow_and_executes_only_after_approval( shadow = engine.evaluate("light.office") assert trained.behavior.status is BehaviorStatus.TRAINED - assert trained.behavior.sample_count == 3 - assert trained.behavior.high_confidence_sample_count == 3 + assert trained.behavior.sample_count == 6 + assert trained.behavior.high_confidence_sample_count == 6 assert shadow.behavior.mode is BehaviorMode.SHADOW assert shadow.behavior.prediction is not None assert shadow.behavior.prediction.target_state == "on" @@ -179,17 +179,27 @@ def test_engine_trains_predicts_in_shadow_and_executes_only_after_approval( ] -def test_engine_excludes_known_automation_actions(tmp_path: Path) -> None: +def test_engine_counts_known_automation_actions_like_manual_actions( + tmp_path: Path, +) -> None: now = datetime.now(timezone.utc).replace(second=0, microsecond=0) engine, _ = _engine(tmp_path, now) trained = engine.train("light.office") - assert {pattern.target_state for pattern in trained.behavior.patterns} == {"on"} - assert {pattern.source for pattern in trained.behavior.patterns} == {"user"} + assert {pattern.target_state for pattern in trained.behavior.patterns} == { + "on", + "off", + } + assert {pattern.source for pattern in trained.behavior.patterns} == { + "user", + "automation", + } + assert trained.behavior.high_confidence_sample_count == 6 + assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0} -def test_engine_learns_causal_automation_for_shadow_without_user_credit( +def test_engine_learns_causal_automation_with_activation_credit( tmp_path: Path, ) -> None: now = datetime.now(timezone.utc).replace(second=0, microsecond=0) @@ -270,7 +280,8 @@ def test_engine_learns_causal_automation_for_shadow_without_user_credit( ] assert len(automation_patterns) == 3 - assert trained.behavior.high_confidence_sample_count == 0 + assert trained.behavior.high_confidence_sample_count == 3 + assert {pattern.weight for pattern in automation_patterns} == {1.0} assert { ( pattern.trigger_entity_id, @@ -280,6 +291,10 @@ def test_engine_learns_causal_automation_for_shadow_without_user_credit( for pattern in automation_patterns } == {("binary_sensor.storage_door", "off", "on")} + active = engine.set_active("light.storage", active=True) + + assert active.behavior.mode is BehaviorMode.ACTIVE + def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None: settings = _settings(tmp_path) @@ -301,7 +316,7 @@ def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None: engine.set_active("lock.front_door", active=True) -def test_active_mode_requires_user_attributed_actions(tmp_path: Path) -> None: +def test_active_mode_requires_trusted_manual_or_automation_actions(tmp_path: Path) -> None: settings = _settings(tmp_path) store = ActuatorStore(settings.actuator_store) record = store.configure("light.office") @@ -321,7 +336,7 @@ def test_active_mode_requires_user_attributed_actions(tmp_path: Path) -> None: reader = FakeBehaviorReader(entities=[], history=[], logbook=[]) engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings) - with pytest.raises(ValueError, match="eindeutig dir zugeordnete"): + with pytest.raises(ValueError, match="manuelle oder automatisierte"): engine.set_active("light.office", active=True) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 5cf84a4..990e4b8 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -15,12 +15,15 @@ def test_dashboard_is_served_at_root() -> None: assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text assert "Freigabe noch gesperrt" in response.text - assert "Bediene das Licht dafür direkt über Home Assistant" in response.text + assert "manuelle oder automatisierte Handlung" in response.text assert "Davon erkannte HA-Automationen" in response.text assert "Aktuelle Situation auswerten" in response.text assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text assert "Kein frischer passender Sensorwechsel erkannt" in response.text assert "Vorhersage jetzt prüfen" not in response.text - assert 'record.behavior.status === "trained" && missingUserActions === 0' in response.text + assert ( + 'record.behavior.status === "trained" && missingTrustedActions === 0' + in response.text + ) assert "Automation-Entwurf" not in response.text assert "Manuelle Overrides" not in response.text