BEHAVIOR-001: learn and predict actuator actions
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user