BEHAVIOR-001: learn and predict actuator actions

This commit is contained in:
2026-06-14 10:37:59 +02:00
parent 6305f52cd2
commit fa250216be
34 changed files with 1614 additions and 489 deletions

View File

@@ -13,7 +13,6 @@ from app.actuators.models import (
AssignmentSource,
LifecycleAuditEntry,
LifecycleStatus,
ManualOverride,
ModelLifecycleState,
ReconciliationState,
model_id_for_actuator,
@@ -57,7 +56,7 @@ _STOPWORDS = frozenset(
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_MIN_MARGIN = 0.18
_CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_MAX_CONTEXT_SELECTIONS = 3
_MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20
@@ -85,21 +84,6 @@ class ActuatorReconciliationService:
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
return self._store.get(actuator_entity_id)
def set_override(
self,
actuator_entity_id: str,
override: ManualOverride | None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
updated = record.model_copy(
update={
"manual_override": override,
"updated_at": datetime.now(timezone.utc),
}
)
self._store.upsert(updated)
return self.reconcile_actuator(actuator_entity_id, trigger="override")
def delete_actuator(self, actuator_entity_id: str) -> None:
model_id = model_id_for_actuator(actuator_entity_id)
self._registry.archive(model_id)
@@ -132,7 +116,7 @@ class ActuatorReconciliationService:
last_summary=(
f"{len(refreshed)} Aktuatoren geprüft, "
f"{sum(1 for record in refreshed if record.assignment.review_required)} "
"mit Prüfbedarf."
"mit niedriger Zuordnungssicherheit."
),
)
self._store.save_reconciliation_state(summary)
@@ -214,7 +198,6 @@ class ActuatorReconciliationService:
actuator=actuator,
numeric_candidates=numeric_candidates,
context_candidates=context_candidates,
override=record.manual_override,
)
lifecycle = self._reconcile_lifecycle(
actuator=actuator,
@@ -225,6 +208,7 @@ class ActuatorReconciliationService:
updated = record.model_copy(
update={
"assignment": assignment,
"manual_override": None,
"numeric_candidates": numeric_candidates,
"context_candidates": context_candidates,
"lifecycle": lifecycle,
@@ -246,27 +230,11 @@ class ActuatorReconciliationService:
actuator: HaEntitySummary,
numeric_candidates: list[AssignmentCandidate],
context_candidates: list[AssignmentCandidate],
override: ManualOverride | None,
) -> AssignmentSelection:
if override is not None:
selected_numeric = override.numeric_entity_id
selected_contexts = list(dict.fromkeys(override.context_entity_ids))
return AssignmentSelection(
selected_numeric_entity_id=selected_numeric,
selected_context_entity_ids=selected_contexts,
source=AssignmentSource.MANUAL,
confidence=1.0 if selected_numeric else 0.6,
review_required=False,
reason=(
"Manuelle Zuordnung überschreibt die automatische Heuristik dauerhaft."
),
)
top_numeric = numeric_candidates[0] if numeric_candidates else None
top_contexts = [
candidate.entity_id
for candidate in context_candidates
if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS]
if top_numeric is None:
return AssignmentSelection(
@@ -275,7 +243,10 @@ class ActuatorReconciliationService:
source=AssignmentSource.NONE,
confidence=0.0,
review_required=True,
reason=f"Kein numerischer Sensor konnte für {display_name(actuator)} bestimmt werden.",
reason=(
f"Für {display_name(actuator)} ist noch kein nutzbarer numerischer "
"Kontext verfügbar. Die Zuordnung wird automatisch erneut geprüft."
),
)
return AssignmentSelection(
@@ -285,9 +256,9 @@ class ActuatorReconciliationService:
confidence=top_numeric.confidence,
review_required=not top_numeric.auto_accepted,
reason=(
"Automatisch akzeptiert."
"Kontext automatisch und eindeutig zugeordnet."
if top_numeric.auto_accepted
else "Top-Kandidat gefunden, aber Zuordnung ist noch nicht eindeutig genug."
else "Besten verfügbaren Kontext automatisch mit niedriger Sicherheit zugeordnet."
),
)
@@ -306,14 +277,6 @@ class ActuatorReconciliationService:
"Ohne numerische Sensorzuordnung wird kein Modell aktiv gehalten.",
now=now,
)
if assignment.review_required and assignment.source is not AssignmentSource.MANUAL:
return self._archive_state(
lifecycle,
"Zuordnung ist nicht eindeutig; Modell wartet auf Review.",
now=now,
status=LifecycleStatus.REVIEW_REQUIRED,
)
sensor_id = assignment.selected_numeric_entity_id
series = self._read_history(sensor_id, now)
points = series.points if series is not None else []
@@ -327,7 +290,7 @@ class ActuatorReconciliationService:
f"{len(points)} von mindestens {self._settings.min_training_points} "
f"Messpunkten für {sensor_id} vorhanden."
),
"next_action": "Mehr Historie sammeln und Reconciliation erneut ausführen.",
"next_action": "Historie wird automatisch weiter gesammelt.",
"last_history_point_count": len(points),
}
),
@@ -369,7 +332,7 @@ class ActuatorReconciliationService:
"last_history_signature": signature,
"last_history_point_count": len(points),
"reason": retrain_reason,
"next_action": "Automatisch überwachen und bei neuen Daten neu trainieren.",
"next_action": "Neue Daten automatisch überwachen und nachtrainieren.",
}
),
action="retrained" if result.replaced else "trained",
@@ -384,8 +347,8 @@ class ActuatorReconciliationService:
"last_reconciled_at": now,
"last_history_signature": signature,
"last_history_point_count": len(points),
"reason": "Modell ist aktuell und passt zur bestätigten Sensorzuordnung.",
"next_action": "Auf neue Historie oder Staleness warten.",
"reason": "Modell ist aktuell und passt zur automatischen Kontextzuordnung.",
"next_action": "Neue Historie automatisch auswerten.",
}
),
action="kept",
@@ -423,7 +386,7 @@ class ActuatorReconciliationService:
"status": status,
"last_reconciled_at": now,
"reason": reason,
"next_action": "Review oder neue Zuordnung erforderlich.",
"next_action": "Bei neuen Home-Assistant-Daten automatisch erneut zuordnen.",
}
),
action="archived",