Add adaptive learning and model rollback
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.2.0 - 2026-06-17
|
||||||
|
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
|
||||||
|
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
|
||||||
|
wertet sie vorsichtig ab.
|
||||||
|
- Modell-Snapshots mit aktivem Modellstand und Rollback-API ergaenzt.
|
||||||
|
- Dashboard zeigt Modell-Snapshots, Rollback, Zeitprofile,
|
||||||
|
adaptive Gewichtungsupdates und Automation-Konflikte.
|
||||||
|
- Automation-Refresh markiert Konflikte, wenn SillyHome aktiv ist und passende
|
||||||
|
HA-Automationen parallel aktiv bleiben.
|
||||||
|
- Zeitprofile fuer Nacht, Morgen, Tag, Abend und Wochenende werden aus
|
||||||
|
gelernten Handlungen gebildet.
|
||||||
|
|
||||||
## 1.1.0 - 2026-06-17
|
## 1.1.0 - 2026-06-17
|
||||||
- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert:
|
- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert:
|
||||||
Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/
|
Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ nach einer ausdrücklichen Freigabe ausführen.
|
|||||||
[`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md)
|
[`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md)
|
||||||
- Version 1.1.0 Safety, Transparenz und Job-Queue:
|
- Version 1.1.0 Safety, Transparenz und Job-Queue:
|
||||||
[`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md)
|
[`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.2.0 adaptive Gewichtung, Rollback und Profile:
|
||||||
|
[`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md)
|
||||||
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
|
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
|
||||||
|
|
||||||
## Reifegrad
|
## Reifegrad
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "1.1.0"
|
version: "1.2.0"
|
||||||
slug: sillyhome_next
|
slug: sillyhome_next
|
||||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||||
|
|||||||
@@ -146,6 +146,14 @@ class DecisionFactor(BaseModel):
|
|||||||
evidence: list[str] = Field(default_factory=list)
|
evidence: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveWeightUpdate(BaseModel):
|
||||||
|
entity_id: str
|
||||||
|
previous_weight: float = Field(ge=0.0, le=1.0)
|
||||||
|
new_weight: float = Field(ge=0.0, le=1.0)
|
||||||
|
reason: str = Field(max_length=300)
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
class SafetyRule(BaseModel):
|
class SafetyRule(BaseModel):
|
||||||
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
label: str = Field(min_length=1, max_length=160)
|
label: str = Field(min_length=1, max_length=160)
|
||||||
@@ -196,6 +204,33 @@ class ExecutionEvent(BaseModel):
|
|||||||
executed_at: datetime
|
executed_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSnapshot(BaseModel):
|
||||||
|
version_id: str
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
sample_count: int = Field(default=0, ge=0)
|
||||||
|
high_confidence_sample_count: int = Field(default=0, ge=0)
|
||||||
|
average_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
incorrect_feedback_count: int = Field(default=0, ge=0)
|
||||||
|
patterns: list[BehaviorPattern] = Field(default_factory=list)
|
||||||
|
reason: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationConflict(BaseModel):
|
||||||
|
automation_entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
|
||||||
|
severity: str = Field(default="info", max_length=20)
|
||||||
|
status: str = Field(default="open", max_length=40)
|
||||||
|
reason: str = Field(max_length=500)
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class TimeProfile(BaseModel):
|
||||||
|
profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
label: str = Field(min_length=1, max_length=80)
|
||||||
|
sample_count: int = Field(default=0, ge=0)
|
||||||
|
dominant_state: str | None = None
|
||||||
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
class RelatedAutomation(BaseModel):
|
class RelatedAutomation(BaseModel):
|
||||||
entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
|
entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
|
||||||
config_id: str = Field(min_length=1, max_length=120)
|
config_id: str = Field(min_length=1, max_length=120)
|
||||||
@@ -230,6 +265,11 @@ class BehaviorState(BaseModel):
|
|||||||
confidence_trend: list[float] = Field(default_factory=list)
|
confidence_trend: list[float] = Field(default_factory=list)
|
||||||
correct_feedback_count: int = Field(default=0, ge=0)
|
correct_feedback_count: int = Field(default=0, ge=0)
|
||||||
incorrect_feedback_count: int = Field(default=0, ge=0)
|
incorrect_feedback_count: int = Field(default=0, ge=0)
|
||||||
|
model_snapshots: list[ModelSnapshot] = Field(default_factory=list)
|
||||||
|
active_model_version: str | None = None
|
||||||
|
adaptive_weight_updates: list[AdaptiveWeightUpdate] = Field(default_factory=list)
|
||||||
|
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
|
||||||
|
time_profiles: list[TimeProfile] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ActuatorRecord(BaseModel):
|
class ActuatorRecord(BaseModel):
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ class SafetyProfileRequest(BaseModel):
|
|||||||
safety: SafetyProfile
|
safety: SafetyProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRollbackRequest(BaseModel):
|
||||||
|
version_id: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
class ActuatorSuggestion(BaseModel):
|
class ActuatorSuggestion(BaseModel):
|
||||||
entity_id: str
|
entity_id: str
|
||||||
domain: str
|
domain: str
|
||||||
@@ -408,6 +412,20 @@ def set_safety_profile(
|
|||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{actuator_entity_id}/model/rollback", response_model=ActuatorRecord)
|
||||||
|
def rollback_model(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
payload: ModelRollbackRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
try:
|
||||||
|
return _behavior(request).rollback_model(actuator_entity_id, version_id=payload.version_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
|
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
|
||||||
def set_activation(
|
def set_activation(
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from app.actuators.models import (
|
from app.actuators.models import (
|
||||||
ActuatorRecord,
|
ActuatorRecord,
|
||||||
|
AdaptiveWeightUpdate,
|
||||||
|
AutomationConflict,
|
||||||
BehaviorMode,
|
BehaviorMode,
|
||||||
BehaviorPattern,
|
BehaviorPattern,
|
||||||
BehaviorPrediction,
|
BehaviorPrediction,
|
||||||
@@ -14,9 +16,12 @@ from app.actuators.models import (
|
|||||||
BehaviorStatus,
|
BehaviorStatus,
|
||||||
DecisionFactor,
|
DecisionFactor,
|
||||||
ExecutionEvent,
|
ExecutionEvent,
|
||||||
|
ManualOverride,
|
||||||
|
ModelSnapshot,
|
||||||
RelatedAutomation,
|
RelatedAutomation,
|
||||||
SafetyProfile,
|
SafetyProfile,
|
||||||
SafetyStage,
|
SafetyStage,
|
||||||
|
TimeProfile,
|
||||||
)
|
)
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
@@ -168,6 +173,7 @@ class BehaviorEngine:
|
|||||||
"eindeutig zugeordnete Handlungen fehlen."
|
"eindeutig zugeordnete Handlungen fehlen."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
model_version_id = f"model-{now.strftime('%Y%m%d%H%M%S')}"
|
||||||
behavior = record.behavior.model_copy(
|
behavior = record.behavior.model_copy(
|
||||||
update={
|
update={
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -182,6 +188,18 @@ class BehaviorEngine:
|
|||||||
"knowledge": _knowledge_lines(record, len(patterns), trusted_actions),
|
"knowledge": _knowledge_lines(record, len(patterns), trusted_actions),
|
||||||
"assumptions": _assumption_lines(record),
|
"assumptions": _assumption_lines(record),
|
||||||
"uncertainties": _uncertainty_lines(record, len(patterns), trusted_actions),
|
"uncertainties": _uncertainty_lines(record, len(patterns), trusted_actions),
|
||||||
|
"time_profiles": _time_profiles(patterns),
|
||||||
|
"model_snapshots": _next_model_snapshots(
|
||||||
|
record.behavior.model_snapshots,
|
||||||
|
model_version_id,
|
||||||
|
patterns[-_MAX_PATTERNS:],
|
||||||
|
len(patterns),
|
||||||
|
trusted_actions,
|
||||||
|
_average(record.behavior.confidence_trend),
|
||||||
|
record.behavior.incorrect_feedback_count,
|
||||||
|
reason,
|
||||||
|
),
|
||||||
|
"active_model_version": model_version_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self._save_behavior(record, behavior)
|
return self._save_behavior(record, behavior)
|
||||||
@@ -454,6 +472,11 @@ class BehaviorEngine:
|
|||||||
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
|
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
|
||||||
correct_count = record.behavior.correct_feedback_count
|
correct_count = record.behavior.correct_feedback_count
|
||||||
incorrect_count = record.behavior.incorrect_feedback_count + 1
|
incorrect_count = record.behavior.incorrect_feedback_count + 1
|
||||||
|
adaptive_updates, manual_override = _adapt_sensor_weights(
|
||||||
|
record,
|
||||||
|
current_context,
|
||||||
|
correct=correct,
|
||||||
|
)
|
||||||
behavior = record.behavior.model_copy(
|
behavior = record.behavior.model_copy(
|
||||||
update={
|
update={
|
||||||
"patterns": patterns[-_MAX_PATTERNS:],
|
"patterns": patterns[-_MAX_PATTERNS:],
|
||||||
@@ -466,6 +489,39 @@ class BehaviorEngine:
|
|||||||
"last_trained_at": now,
|
"last_trained_at": now,
|
||||||
"correct_feedback_count": correct_count,
|
"correct_feedback_count": correct_count,
|
||||||
"incorrect_feedback_count": incorrect_count,
|
"incorrect_feedback_count": incorrect_count,
|
||||||
|
"adaptive_weight_updates": [
|
||||||
|
*record.behavior.adaptive_weight_updates,
|
||||||
|
*adaptive_updates,
|
||||||
|
][-50:],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
record_for_save = (
|
||||||
|
record.model_copy(update={"manual_override": manual_override})
|
||||||
|
if manual_override is not None
|
||||||
|
else record
|
||||||
|
)
|
||||||
|
return self._save_behavior(record_for_save, behavior)
|
||||||
|
|
||||||
|
def rollback_model(
|
||||||
|
self,
|
||||||
|
actuator_entity_id: str,
|
||||||
|
*,
|
||||||
|
version_id: str,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
record = self._store.get(actuator_entity_id)
|
||||||
|
snapshot = next(
|
||||||
|
(item for item in record.behavior.model_snapshots if item.version_id == version_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if snapshot is None:
|
||||||
|
raise ValueError("Modell-Snapshot nicht gefunden.")
|
||||||
|
behavior = record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"patterns": snapshot.patterns,
|
||||||
|
"sample_count": snapshot.sample_count,
|
||||||
|
"high_confidence_sample_count": snapshot.high_confidence_sample_count,
|
||||||
|
"active_model_version": snapshot.version_id,
|
||||||
|
"reason": f"Rollback auf Modell-Snapshot {snapshot.version_id}.",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self._save_behavior(record, behavior)
|
return self._save_behavior(record, behavior)
|
||||||
@@ -499,7 +555,10 @@ class BehaviorEngine:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
behavior = record.behavior.model_copy(
|
behavior = record.behavior.model_copy(
|
||||||
update={"related_automations": related}
|
update={
|
||||||
|
"related_automations": related,
|
||||||
|
"automation_conflicts": _automation_conflicts(record, related),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return self._save_behavior(record, behavior)
|
return self._save_behavior(record, behavior)
|
||||||
|
|
||||||
@@ -990,6 +1049,165 @@ def _uncertainty_lines(
|
|||||||
return lines or ["Keine kritische Unsicherheit aus den lokalen Daten erkannt."]
|
return lines or ["Keine kritische Unsicherheit aus den lokalen Daten erkannt."]
|
||||||
|
|
||||||
|
|
||||||
|
def _next_model_snapshots(
|
||||||
|
existing: list[ModelSnapshot],
|
||||||
|
version_id: str,
|
||||||
|
patterns: list[BehaviorPattern],
|
||||||
|
sample_count: int,
|
||||||
|
trusted_actions: int,
|
||||||
|
average_confidence: float,
|
||||||
|
incorrect_feedback_count: int,
|
||||||
|
reason: str,
|
||||||
|
) -> list[ModelSnapshot]:
|
||||||
|
snapshot = ModelSnapshot(
|
||||||
|
version_id=version_id,
|
||||||
|
sample_count=sample_count,
|
||||||
|
high_confidence_sample_count=trusted_actions,
|
||||||
|
average_confidence=round(average_confidence, 4),
|
||||||
|
incorrect_feedback_count=incorrect_feedback_count,
|
||||||
|
patterns=patterns,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
return [*existing, snapshot][-10:]
|
||||||
|
|
||||||
|
|
||||||
|
def _average(values: list[float]) -> float:
|
||||||
|
return sum(values) / len(values) if values else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _time_profiles(patterns: list[BehaviorPattern]) -> list[TimeProfile]:
|
||||||
|
buckets = {
|
||||||
|
"night": ("Nacht", range(0, 360)),
|
||||||
|
"morning": ("Morgen", range(360, 720)),
|
||||||
|
"day": ("Tag", range(720, 1080)),
|
||||||
|
"evening": ("Abend", range(1080, 1440)),
|
||||||
|
}
|
||||||
|
profiles: list[TimeProfile] = []
|
||||||
|
for profile_id, (label, minutes) in buckets.items():
|
||||||
|
selected = [pattern for pattern in patterns if pattern.minute_of_day in minutes]
|
||||||
|
if not selected:
|
||||||
|
profiles.append(TimeProfile(profile_id=profile_id, label=label))
|
||||||
|
continue
|
||||||
|
by_state: dict[str, int] = {}
|
||||||
|
for pattern in selected:
|
||||||
|
by_state[pattern.target_state] = by_state.get(pattern.target_state, 0) + 1
|
||||||
|
dominant_state, count = max(by_state.items(), key=lambda item: (item[1], item[0]))
|
||||||
|
profiles.append(
|
||||||
|
TimeProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
label=label,
|
||||||
|
sample_count=len(selected),
|
||||||
|
dominant_state=dominant_state,
|
||||||
|
confidence=round(count / len(selected), 4),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
weekend = [pattern for pattern in patterns if pattern.weekday >= 5]
|
||||||
|
profiles.append(
|
||||||
|
TimeProfile(
|
||||||
|
profile_id="weekend",
|
||||||
|
label="Wochenende",
|
||||||
|
sample_count=len(weekend),
|
||||||
|
dominant_state=(
|
||||||
|
max(
|
||||||
|
{pattern.target_state: 0 for pattern in weekend},
|
||||||
|
key=lambda state: sum(pattern.target_state == state for pattern in weekend),
|
||||||
|
)
|
||||||
|
if weekend
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
confidence=round(len(weekend) / len(patterns), 4) if patterns else 0.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
|
def _adapt_sensor_weights(
|
||||||
|
record: ActuatorRecord,
|
||||||
|
current_context: dict[str, str | None],
|
||||||
|
*,
|
||||||
|
correct: bool,
|
||||||
|
) -> tuple[list[AdaptiveWeightUpdate], ManualOverride | None]:
|
||||||
|
if not current_context:
|
||||||
|
return [], record.manual_override
|
||||||
|
candidates = {
|
||||||
|
candidate.entity_id: candidate
|
||||||
|
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
||||||
|
}
|
||||||
|
previous = record.manual_override
|
||||||
|
weights = dict(previous.sensor_weights if previous is not None else {})
|
||||||
|
updates: list[AdaptiveWeightUpdate] = []
|
||||||
|
delta = 0.03 if correct else -0.08
|
||||||
|
for entity_id in current_context:
|
||||||
|
candidate = candidates.get(entity_id)
|
||||||
|
base = weights.get(
|
||||||
|
entity_id,
|
||||||
|
candidate.effective_weight if candidate is not None else 1.0,
|
||||||
|
)
|
||||||
|
new_weight = round(min(1.0, max(0.1, base + delta)), 4)
|
||||||
|
if new_weight == base:
|
||||||
|
continue
|
||||||
|
weights[entity_id] = new_weight
|
||||||
|
updates.append(
|
||||||
|
AdaptiveWeightUpdate(
|
||||||
|
entity_id=entity_id,
|
||||||
|
previous_weight=round(base, 4),
|
||||||
|
new_weight=new_weight,
|
||||||
|
reason=(
|
||||||
|
"Feedback korrekt: Kontextsignal leicht höher gewichtet."
|
||||||
|
if correct
|
||||||
|
else "Feedback falsch: Kontextsignal vorsichtig abgewertet."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not updates:
|
||||||
|
return [], previous
|
||||||
|
return updates, ManualOverride(
|
||||||
|
numeric_entity_id=(
|
||||||
|
previous.numeric_entity_id
|
||||||
|
if previous is not None
|
||||||
|
else record.assignment.selected_numeric_entity_id
|
||||||
|
),
|
||||||
|
context_entity_ids=(
|
||||||
|
previous.context_entity_ids
|
||||||
|
if previous is not None
|
||||||
|
else record.assignment.selected_context_entity_ids
|
||||||
|
),
|
||||||
|
sensor_weights=weights,
|
||||||
|
sensor_weight_groups=previous.sensor_weight_groups if previous is not None else [],
|
||||||
|
note="Sensor-Gewichtungen automatisch aus Feedback angepasst.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_conflicts(
|
||||||
|
record: ActuatorRecord,
|
||||||
|
related: list[RelatedAutomation],
|
||||||
|
) -> list[AutomationConflict]:
|
||||||
|
conflicts: list[AutomationConflict] = []
|
||||||
|
for automation in related:
|
||||||
|
if record.behavior.mode is BehaviorMode.ACTIVE and automation.enabled:
|
||||||
|
conflicts.append(
|
||||||
|
AutomationConflict(
|
||||||
|
automation_entity_id=automation.entity_id,
|
||||||
|
severity="warning",
|
||||||
|
status="open",
|
||||||
|
reason=(
|
||||||
|
"SillyHome ist aktiv, aber diese passende HA-Automation "
|
||||||
|
"ist ebenfalls aktiv. Das kann zu konkurrierenden Schaltungen führen."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif automation.entity_id in record.behavior.paused_automation_entity_ids:
|
||||||
|
conflicts.append(
|
||||||
|
AutomationConflict(
|
||||||
|
automation_entity_id=automation.entity_id,
|
||||||
|
severity="info",
|
||||||
|
status="controlled",
|
||||||
|
reason="Automation ist durch SillyHome pausiert.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
def predict_behavior(
|
def predict_behavior(
|
||||||
patterns: list[BehaviorPattern],
|
patterns: list[BehaviorPattern],
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SillyHome Next API",
|
title="SillyHome Next API",
|
||||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||||
version="1.1.0",
|
version="1.2.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
|
|||||||
@@ -903,6 +903,11 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
const knowledge = record.behavior.knowledge || [];
|
const knowledge = record.behavior.knowledge || [];
|
||||||
const assumptions = record.behavior.assumptions || [];
|
const assumptions = record.behavior.assumptions || [];
|
||||||
const uncertainties = record.behavior.uncertainties || [];
|
const uncertainties = record.behavior.uncertainties || [];
|
||||||
|
const snapshots = record.behavior.model_snapshots || [];
|
||||||
|
const activeModelVersion = record.behavior.active_model_version || "";
|
||||||
|
const adaptiveUpdates = record.behavior.adaptive_weight_updates || [];
|
||||||
|
const automationConflicts = record.behavior.automation_conflicts || [];
|
||||||
|
const timeProfiles = record.behavior.time_profiles || [];
|
||||||
const safetyControls = `
|
const safetyControls = `
|
||||||
<details class="manual-context" open>
|
<details class="manual-context" open>
|
||||||
<summary>Sicherheit und manuelles Gegensteuern</summary>
|
<summary>Sicherheit und manuelles Gegensteuern</summary>
|
||||||
@@ -962,6 +967,43 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`;
|
||||||
|
const adaptivePanel = `
|
||||||
|
<details class="manual-context">
|
||||||
|
<summary>v1.2 Lernen, Rollback und Konflikte</summary>
|
||||||
|
<h3>Zeitprofile</h3>
|
||||||
|
<div class="metric-grid">
|
||||||
|
${timeProfiles.length ? timeProfiles.map(profile => `
|
||||||
|
<div class="metric">
|
||||||
|
<strong>${escapeHtml(profile.label)}</strong>
|
||||||
|
${escapeHtml(profile.sample_count)} Samples · ${escapeHtml(profile.dominant_state || "offen")}
|
||||||
|
<p class="muted">${Math.round((profile.confidence || 0) * 100)} % Profilklarheit</p>
|
||||||
|
</div>
|
||||||
|
`).join("") : "<div class='metric'><strong>Zeitprofile</strong>Noch keine Daten</div>"}
|
||||||
|
</div>
|
||||||
|
<h3>Modell-Snapshots</h3>
|
||||||
|
<div class="decision-list">
|
||||||
|
${snapshots.length ? snapshots.slice(-5).reverse().map(snapshot => `
|
||||||
|
<div class="decision-row">
|
||||||
|
<header>
|
||||||
|
<strong>${escapeHtml(snapshot.version_id)}</strong>
|
||||||
|
<span class="chip">${snapshot.version_id === activeModelVersion ? "aktiv" : "Rollback möglich"}</span>
|
||||||
|
</header>
|
||||||
|
<p class="muted">${escapeHtml(snapshot.sample_count)} Samples · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %</p>
|
||||||
|
<p>${escapeHtml(snapshot.reason || "Kein Kommentar")}</p>
|
||||||
|
${snapshot.version_id !== activeModelVersion ? `<button class="secondary compact" onclick="rollbackModel('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(snapshot.version_id)}')">Rollback</button>` : ""}
|
||||||
|
</div>
|
||||||
|
`).join("") : "<p class='muted'>Noch kein Modell-Snapshot gespeichert.</p>"}
|
||||||
|
</div>
|
||||||
|
<h3>Automatische Gewichtsanpassungen</h3>
|
||||||
|
<ul>${adaptiveUpdates.length ? adaptiveUpdates.slice(-8).reverse().map(update => `
|
||||||
|
<li><code>${escapeHtml(update.entity_id)}</code>: ${Math.round(update.previous_weight * 100)} % → ${Math.round(update.new_weight * 100)} %. ${escapeHtml(update.reason)}</li>
|
||||||
|
`).join("") : "<li>Noch keine automatische Gewichtsanpassung.</li>"}</ul>
|
||||||
|
<h3>Automation-Konflikte</h3>
|
||||||
|
<ul>${automationConflicts.length ? automationConflicts.map(conflict => `
|
||||||
|
<li><code>${escapeHtml(conflict.automation_entity_id)}</code>: <span class="${conflict.severity === "warning" ? "warn" : "muted"}">${escapeHtml(conflict.status)}</span> ${escapeHtml(conflict.reason)}</li>
|
||||||
|
`).join("") : "<li>Keine aktiven Automation-Konflikte erkannt.</li>"}</ul>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
const learnedAutomationActions = record.behavior.patterns.filter(
|
const learnedAutomationActions = record.behavior.patterns.filter(
|
||||||
pattern => pattern.source === "automation",
|
pattern => pattern.source === "automation",
|
||||||
).length;
|
).length;
|
||||||
@@ -1073,6 +1115,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
</div>
|
</div>
|
||||||
${safetyControls}
|
${safetyControls}
|
||||||
${decisionArchive}
|
${decisionArchive}
|
||||||
|
${adaptivePanel}
|
||||||
<h3>Passende Home-Assistant-Automationen</h3>
|
<h3>Passende Home-Assistant-Automationen</h3>
|
||||||
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
|
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
|
||||||
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
|
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
|
||||||
@@ -1305,6 +1348,21 @@ async function saveSafetyProfile(actuatorId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function rollbackModel(actuatorId, versionId) {
|
||||||
|
if (!confirm(`${actuatorId}: wirklich auf Modell ${versionId} zurückrollen?`)) return;
|
||||||
|
try {
|
||||||
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/model/rollback`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({version_id: versionId}),
|
||||||
|
});
|
||||||
|
invalidateDashboardCache();
|
||||||
|
await loadConfiguredActuators();
|
||||||
|
await showActuator(actuatorId, `Rollback auf ${versionId} ausgeführt.`);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
|
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
|
||||||
const question = active
|
const question = active
|
||||||
? pauseMatchingAutomations
|
? pauseMatchingAutomations
|
||||||
|
|||||||
62
docs/V1_2_0_OPERATING_GUIDE.md
Normal file
62
docs/V1_2_0_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# SillyHome Next v1.2.0 Operating Guide
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
v1.2.0 erweitert die sichere v1.1-Grundlage um adaptive Lernfunktionen. Diese
|
||||||
|
Funktionen laufen bei Feedback, Training oder Automation-Refresh und blockieren
|
||||||
|
nicht den direkten Schaltpfad.
|
||||||
|
|
||||||
|
## Adaptive Gewichtung
|
||||||
|
|
||||||
|
Feedback passt die Gewichtung aktuell beteiligter Kontextsignale vorsichtig an:
|
||||||
|
|
||||||
|
- korrektes Feedback: +3 Prozentpunkte bis maximal 100 %
|
||||||
|
- falsches Feedback: -8 Prozentpunkte bis minimal 10 %
|
||||||
|
|
||||||
|
Die Aenderungen werden als `adaptive_weight_updates` gespeichert und im
|
||||||
|
Dashboard angezeigt. Manuelle Gewichtungen bleiben weiter direkt korrigierbar.
|
||||||
|
|
||||||
|
## Modell-Snapshots und Rollback
|
||||||
|
|
||||||
|
Bei jedem Training wird ein Snapshot gespeichert:
|
||||||
|
|
||||||
|
- Version-ID
|
||||||
|
- Sample Count
|
||||||
|
- eindeutig zugeordnete Handlungen
|
||||||
|
- durchschnittliche Confidence
|
||||||
|
- negative Feedbacks
|
||||||
|
- Musterliste
|
||||||
|
- Begruendung
|
||||||
|
|
||||||
|
Ueber das Dashboard kann auf einen frueheren Snapshot zurueckgerollt werden.
|
||||||
|
|
||||||
|
## Automation-Konflikte
|
||||||
|
|
||||||
|
Beim Automation-Refresh markiert SillyHome Konflikte, wenn:
|
||||||
|
|
||||||
|
- SillyHome fuer einen Aktor aktiv ist
|
||||||
|
- eine passende Home-Assistant-Automation ebenfalls aktiv bleibt
|
||||||
|
|
||||||
|
Pausierte Automationen werden als kontrolliert markiert.
|
||||||
|
|
||||||
|
## Zeitprofile
|
||||||
|
|
||||||
|
SillyHome bildet Profile fuer:
|
||||||
|
|
||||||
|
- Nacht
|
||||||
|
- Morgen
|
||||||
|
- Tag
|
||||||
|
- Abend
|
||||||
|
- Wochenende
|
||||||
|
|
||||||
|
Diese Profile zeigen Sample Count, dominanten Zielzustand und Profilklarheit.
|
||||||
|
|
||||||
|
## Performance-Grenze
|
||||||
|
|
||||||
|
v1.2-Funktionen duerfen den Schaltmoment nicht verlangsamen. Der direkte
|
||||||
|
Schaltpfad bleibt:
|
||||||
|
|
||||||
|
1. vorhandene aktuelle States nutzen
|
||||||
|
2. lokale Safety-Pruefung
|
||||||
|
3. direkter Home-Assistant-Serviceaufruf
|
||||||
|
4. Persistenz der Entscheidung
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
|
from app.actuators.models import ModelSnapshot
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.behavior.engine import BehaviorEngine
|
from app.behavior.engine import BehaviorEngine
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
@@ -113,6 +114,7 @@ def _install_service(tmp_path: Path) -> None:
|
|||||||
unit_of_measurement="lx",
|
unit_of_measurement="lx",
|
||||||
friendly_name="Abstellkammer Helligkeit",
|
friendly_name="Abstellkammer Helligkeit",
|
||||||
area_name="Abstellkammer",
|
area_name="Abstellkammer",
|
||||||
|
state="12",
|
||||||
),
|
),
|
||||||
HaEntitySummary(
|
HaEntitySummary(
|
||||||
entity_id="binary_sensor.abstellkammer_motion",
|
entity_id="binary_sensor.abstellkammer_motion",
|
||||||
@@ -120,6 +122,7 @@ def _install_service(tmp_path: Path) -> None:
|
|||||||
device_class="motion",
|
device_class="motion",
|
||||||
friendly_name="Abstellkammer Bewegung",
|
friendly_name="Abstellkammer Bewegung",
|
||||||
area_name="Abstellkammer",
|
area_name="Abstellkammer",
|
||||||
|
state="off",
|
||||||
),
|
),
|
||||||
HaEntitySummary(
|
HaEntitySummary(
|
||||||
entity_id="sensor.pfsense_interface_vpn_inbytes",
|
entity_id="sensor.pfsense_interface_vpn_inbytes",
|
||||||
@@ -300,6 +303,54 @@ def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
|
|||||||
assert payload["behavior"]["safety"]["cooldown_seconds"] == 120
|
assert payload["behavior"]["safety"]["cooldown_seconds"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post(
|
||||||
|
"/v1/actuators",
|
||||||
|
json={"actuator_entity_id": "light.abstellkammer"},
|
||||||
|
)
|
||||||
|
record = app.state.actuator_store.get("light.abstellkammer")
|
||||||
|
version_id = "model-test"
|
||||||
|
snapshot = ModelSnapshot(
|
||||||
|
version_id=version_id,
|
||||||
|
sample_count=1,
|
||||||
|
high_confidence_sample_count=1,
|
||||||
|
average_confidence=0.9,
|
||||||
|
patterns=[],
|
||||||
|
reason="Test-Snapshot",
|
||||||
|
)
|
||||||
|
app.state.actuator_store.upsert(
|
||||||
|
record.model_copy(
|
||||||
|
update={
|
||||||
|
"behavior": record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"model_snapshots": [snapshot],
|
||||||
|
"active_model_version": "model-current",
|
||||||
|
"sample_count": 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
feedback = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/feedback",
|
||||||
|
json={"correct": False, "expected_state": "off"},
|
||||||
|
)
|
||||||
|
rollback = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/model/rollback",
|
||||||
|
json={"version_id": version_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert feedback.status_code == 200
|
||||||
|
feedback_payload = feedback.json()
|
||||||
|
assert feedback_payload["behavior"]["adaptive_weight_updates"]
|
||||||
|
assert feedback_payload["manual_override"]["sensor_weights"]
|
||||||
|
assert rollback.status_code == 200
|
||||||
|
assert rollback.json()["behavior"]["active_model_version"] == version_id
|
||||||
|
|
||||||
|
|
||||||
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_install_service(tmp_path)
|
_install_service(tmp_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user