BEHAVIOR-003: learn causal shadow triggers
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled

This commit is contained in:
2026-06-14 15:35:07 +02:00
parent 1370d02c15
commit fb76d89204
11 changed files with 278 additions and 4 deletions

View File

@@ -76,6 +76,7 @@ def test_entities_returns_reader_data() -> None:
"entity_id": "sensor.temperature",
"domain": "sensor",
"state": None,
"last_changed": None,
"state_class": None,
"device_class": None,
"unit_of_measurement": None,

View File

@@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from app.actuators.models import BehaviorMode, BehaviorStatus
from app.actuators.models import BehaviorMode, BehaviorPattern, BehaviorStatus
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state
from app.config import Settings
@@ -189,6 +189,98 @@ def test_engine_excludes_known_automation_actions(tmp_path: Path) -> None:
assert {pattern.source for pattern in trained.behavior.patterns} == {"user"}
def test_engine_learns_causal_automation_for_shadow_without_user_credit(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
actuator_points: list[StateHistoryPoint] = []
door_points: list[StateHistoryPoint] = []
logbook: list[LogbookEntry] = []
for days_ago in (3, 2, 1):
action_at = now - timedelta(days=days_ago)
actuator_points.extend(
[
StateHistoryPoint(
timestamp=action_at - timedelta(minutes=1),
state="off",
),
StateHistoryPoint(timestamp=action_at, state="on"),
]
)
door_points.extend(
[
StateHistoryPoint(
timestamp=action_at - timedelta(minutes=1),
state="off",
),
StateHistoryPoint(
timestamp=action_at - timedelta(seconds=1),
state="on",
),
]
)
logbook.append(
LogbookEntry(
entity_id="light.storage",
timestamp=action_at,
message="turned on",
context_domain="automation",
context_service="trigger",
)
)
actuator_points.sort(key=lambda point: point.timestamp)
door_points.sort(key=lambda point: point.timestamp)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
store.upsert(
record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.storage_door"
],
}
)
}
)
)
reader = FakeBehaviorReader(
entities=[],
history=[
StateHistorySeries(
entity_id="light.storage",
points=actuator_points,
),
StateHistorySeries(
entity_id="binary_sensor.storage_door",
points=door_points,
),
],
logbook=logbook,
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
trained = engine.train("light.storage")
automation_patterns = [
pattern
for pattern in trained.behavior.patterns
if pattern.source == "automation"
]
assert len(automation_patterns) == 3
assert trained.behavior.high_confidence_sample_count == 0
assert {
(
pattern.trigger_entity_id,
pattern.trigger_from_state,
pattern.trigger_to_state,
)
for pattern in automation_patterns
} == {("binary_sensor.storage_door", "off", "on")}
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
@@ -259,3 +351,66 @@ def test_prediction_requires_temporal_support() -> None:
min_support=3,
window_minutes=30,
) is None
def test_prediction_uses_fresh_causal_context_transition_outside_time_window() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=0.7,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
]
prediction = predict_behavior(
patterns,
current_context={"binary_sensor.storage_door": "on"},
current_context_changed_at={
"binary_sensor.storage_door": now - timedelta(seconds=10)
},
now=now,
min_support=3,
window_minutes=30,
)
assert prediction is not None
assert prediction.target_state == "on"
assert prediction.matching_patterns == 3
assert prediction.confidence == 0.7
assert "frischen Sensorwechsel" in prediction.reason
def test_prediction_ignores_stale_causal_context_state() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
pattern = BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=0.7,
observed_at=now - timedelta(days=1),
)
assert predict_behavior(
[pattern],
current_context={"binary_sensor.storage_door": "on"},
current_context_changed_at={
"binary_sensor.storage_door": now - timedelta(minutes=5)
},
now=now,
min_support=1,
window_minutes=30,
) is None

View File

@@ -15,6 +15,7 @@ class FakeHaClient(HaClient):
{
"entity_id": "sensor.temperature",
"state": "21.5",
"last_changed": "2026-06-14T12:00:00+00:00",
"attributes": {
"state_class": "measurement",
"device_class": "temperature",
@@ -87,6 +88,7 @@ def test_ha_reader_returns_summaries() -> None:
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
assert sensor.unit_of_measurement == "°C"
assert sensor.state == "21.5"
assert sensor.last_changed == datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc)
assert sensor.area_name == "Kueche"
assert sensor.device_name == "Thermometer"

View File

@@ -16,6 +16,7 @@ def test_dashboard_is_served_at_root() -> None:
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 "Davon erkannte HA-Automationen" in response.text
assert 'record.behavior.status === "trained" && missingUserActions === 0' in response.text
assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text