Add adaptive learning and model rollback
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-17 18:41:03 +02:00
parent 0101596e93
commit 2ec2c64cba
11 changed files with 465 additions and 4 deletions

View File

@@ -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/

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

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

View File

@@ -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],
*, *,

View File

@@ -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()

View File

@@ -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

View 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

View File

@@ -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 = [

View File

@@ -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)