BEHAVIOR-003: learn causal shadow triggers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user