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",

View File

@@ -25,6 +25,18 @@ class LifecycleStatus(StrEnum):
ARCHIVED = "archived"
class BehaviorMode(StrEnum):
SHADOW = "shadow"
ACTIVE = "active"
PAUSED = "paused"
class BehaviorStatus(StrEnum):
COLLECTING = "collecting"
TRAINED = "trained"
BLOCKED = "blocked"
class AssignmentCandidate(BaseModel):
entity_id: str
domain: str
@@ -71,10 +83,49 @@ class ModelLifecycleState(BaseModel):
last_history_signature: str | None = None
last_history_point_count: int = Field(default=0, ge=0)
reason: str = "Noch keine Trainingsdaten ausgewertet."
next_action: str = "Aktuator auswählen und Zuordnung prüfen."
next_action: str = "Aktor auswählen; Kontext und Historie werden automatisch geprüft."
audit: list[LifecycleAuditEntry] = Field(default_factory=list)
class BehaviorPattern(BaseModel):
target_state: str = Field(min_length=1, max_length=100)
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)
source: str = Field(default="observed", max_length=40)
weight: float = Field(default=1.0, ge=0.1, le=1.0)
observed_at: datetime
class BehaviorPrediction(BaseModel):
target_state: str
confidence: float = Field(ge=0.0, le=1.0)
generated_at: datetime
reason: str
matching_patterns: int = Field(default=0, ge=0)
executed: bool = False
class ExecutionEvent(BaseModel):
target_state: str
executed_at: datetime
class BehaviorState(BaseModel):
mode: BehaviorMode = BehaviorMode.SHADOW
status: BehaviorStatus = BehaviorStatus.COLLECTING
approved_at: datetime | None = None
sample_count: int = Field(default=0, ge=0)
high_confidence_sample_count: int = Field(default=0, ge=0)
patterns: list[BehaviorPattern] = Field(default_factory=list)
prediction: BehaviorPrediction | None = None
last_trained_at: datetime | None = None
last_evaluated_at: datetime | None = None
last_executed_at: datetime | None = None
execution_events: list[ExecutionEvent] = Field(default_factory=list)
reason: str = "Historische Aktorhandlungen werden analysiert."
class ActuatorRecord(BaseModel):
actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
enabled: bool = True
@@ -85,6 +136,7 @@ class ActuatorRecord(BaseModel):
numeric_candidates: list[AssignmentCandidate] = Field(default_factory=list)
context_candidates: list[AssignmentCandidate] = Field(default_factory=list)
lifecycle: ModelLifecycleState
behavior: BehaviorState = Field(default_factory=BehaviorState)
class ReconciliationState(BaseModel):