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

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

@@ -92,6 +92,9 @@ class BehaviorPattern(BaseModel):
minute_of_day: int = Field(ge=0, le=1439)
weekday: int = Field(ge=0, le=6)
context_states: dict[str, str] = Field(default_factory=dict)
trigger_entity_id: str | None = None
trigger_from_state: str | None = None
trigger_to_state: str | None = None
source: str = Field(default="observed", max_length=40)
weight: float = Field(default=1.0, ge=0.1, le=1.0)
observed_at: datetime

View File

@@ -22,6 +22,7 @@ from app.ha.reader import HaReader
_MAX_PATTERNS = 500
_MAX_EXECUTION_EVENTS = 100
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"})
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
@@ -198,12 +199,18 @@ class BehaviorEngine:
)
if entity_id and entity_id in entities and entities[entity_id].state is not None
}
current_context_changed_at = {
entity_id: entities[entity_id].last_changed
for entity_id in current_context
}
prediction = predict_behavior(
record.behavior.patterns,
current_context=current_context,
current_context_changed_at=current_context_changed_at,
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
timezone_name=self._settings.timezone,
)
behavior = record.behavior.model_copy(
@@ -326,7 +333,12 @@ class BehaviorEngine:
if _matches_own_execution(point, own_executions):
continue
source, weight = _action_source(point, logbook)
if source == "automation":
trigger = _recent_context_transition(
context_history,
context_ids,
point.timestamp,
)
if source == "automation" and trigger is None:
continue
contexts = {
entity_id: state
@@ -340,6 +352,9 @@ class BehaviorEngine:
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states=contexts,
trigger_entity_id=trigger[0] if trigger else None,
trigger_from_state=trigger[1] if trigger else None,
trigger_to_state=trigger[2] if trigger else None,
source=source,
weight=weight,
observed_at=point.timestamp,
@@ -373,14 +388,52 @@ def predict_behavior(
now: datetime,
min_support: int,
window_minutes: int,
current_context_changed_at: dict[str, datetime | None] | None = None,
causal_window_seconds: int = 120,
timezone_name: str = "Europe/Berlin",
) -> BehaviorPrediction | None:
if not patterns:
return None
local = now.astimezone(ZoneInfo(timezone_name))
minute_of_day = local.hour * 60 + local.minute
changed_at = current_context_changed_at or {}
by_state: dict[str, list[float]] = {}
causal_support_by_state: dict[str, int] = {}
for pattern in patterns:
if pattern.trigger_entity_id and pattern.trigger_to_state:
trigger_changed_at = changed_at.get(pattern.trigger_entity_id)
trigger_age = (
(now - trigger_changed_at).total_seconds()
if trigger_changed_at is not None
else None
)
if not (
current_context.get(pattern.trigger_entity_id)
== pattern.trigger_to_state
and trigger_age is not None
and 0 <= trigger_age <= causal_window_seconds
):
continue
comparable = [
(entity_id, expected)
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
context_score = (
sum(
current_context[entity_id] == expected
for entity_id, expected in comparable
)
/ len(comparable)
if comparable
else 0.5
)
score = pattern.weight * (0.85 + 0.15 * context_score)
by_state.setdefault(pattern.target_state, []).append(score)
causal_support_by_state[pattern.target_state] = (
causal_support_by_state.get(pattern.target_state, 0) + 1
)
continue
distance = _circular_minute_distance(minute_of_day, pattern.minute_of_day)
if distance > window_minutes:
continue
@@ -414,6 +467,7 @@ def predict_behavior(
key=lambda item: (sum(item[1]), len(item[1]), item[0]),
)
support = len(scores)
causal_support = causal_support_by_state.get(target_state, 0)
confidence = min(1.0, (sum(scores) / support) * min(1.0, support / min_support))
if confidence <= 0:
return None
@@ -423,7 +477,12 @@ def predict_behavior(
generated_at=now,
matching_patterns=support,
reason=(
f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext."
(
f"{causal_support} historische Handlungen folgten demselben "
"frischen Sensorwechsel."
)
if causal_support
else f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext."
),
)
@@ -476,6 +535,32 @@ def _matches_own_execution(
)
def _recent_context_transition(
history: dict[str, StateHistorySeries],
context_ids: list[str],
timestamp: datetime,
) -> tuple[str, str, str] | None:
nearest: tuple[timedelta, str, str, str] | None = None
for entity_id in context_ids:
series = history.get(entity_id)
if series is None:
continue
previous_state: str | None = None
for point in series.points:
if point.timestamp > timestamp:
break
if previous_state is not None and point.state != previous_state:
age = timestamp - point.timestamp
if age <= _CONTEXT_TRIGGER_TOLERANCE and (
nearest is None or age < nearest[0]
):
nearest = (age, entity_id, previous_state, point.state)
previous_state = point.state
if nearest is None:
return None
return nearest[1], nearest[2], nearest[3]
def _circular_minute_distance(left: int, right: int) -> int:
direct = abs(left - right)
return min(direct, 1440 - direct)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
@@ -15,6 +17,7 @@ class HaEntitySummary(BaseModel):
entity_id: str
domain: str
state: str | None = None
last_changed: datetime | None = None
state_class: str | None = None
device_class: str | None = None
unit_of_measurement: str | None = None

View File

@@ -53,6 +53,7 @@ class HaReader:
entity_id=entity_id,
domain=domain,
state=_optional_str(item.get("state")),
last_changed=_optional_datetime(item.get("last_changed")),
state_class=_optional_str(attributes.get("state_class")),
device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
@@ -116,3 +117,13 @@ def _optional_str(value: object) -> str | None:
if value is None or value == "":
return None
return str(value)
def _optional_datetime(value: object) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo is not None else None

View File

@@ -240,6 +240,9 @@ async function showActuator(actuatorId) {
.join("");
const prediction = record.behavior.prediction;
const requiredUserActions = 3;
const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation",
).length;
const missingUserActions = Math.max(
0,
requiredUserActions - record.behavior.high_confidence_sample_count,
@@ -266,6 +269,7 @@ async function showActuator(actuatorId) {
<p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p>
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
<p><strong>Davon eindeutig Benutzer:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Davon erkannte HA-Automationen:</strong> ${learnedAutomationActions}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
${activationButton}