diff --git a/CHANGELOG.md b/CHANGELOG.md
index a9c2141..b505e61 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# 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
- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert:
Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/
diff --git a/README.md b/README.md
index 70a3846..950d49d 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,8 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md)
- Version 1.1.0 Safety, Transparenz und Job-Queue:
[`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)
## Reifegrad
diff --git a/addon/config.yaml b/addon/config.yaml
index 798567b..91d3f3a 100644
--- a/addon/config.yaml
+++ b/addon/config.yaml
@@ -1,5 +1,5 @@
name: SillyHome Next
-version: "1.1.0"
+version: "1.2.0"
slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next
diff --git a/app/actuators/models.py b/app/actuators/models.py
index 94d09e4..ada424e 100644
--- a/app/actuators/models.py
+++ b/app/actuators/models.py
@@ -146,6 +146,14 @@ class DecisionFactor(BaseModel):
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):
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=160)
@@ -196,6 +204,33 @@ class ExecutionEvent(BaseModel):
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):
entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
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)
correct_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):
diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py
index 4454a8c..6a8b779 100644
--- a/app/api/v1/actuators.py
+++ b/app/api/v1/actuators.py
@@ -60,6 +60,10 @@ class SafetyProfileRequest(BaseModel):
safety: SafetyProfile
+class ModelRollbackRequest(BaseModel):
+ version_id: str = Field(min_length=1, max_length=120)
+
+
class ActuatorSuggestion(BaseModel):
entity_id: str
domain: str
@@ -408,6 +412,20 @@ def set_safety_profile(
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)
def set_activation(
actuator_entity_id: str,
diff --git a/app/behavior/engine.py b/app/behavior/engine.py
index 332e101..e0ed2e3 100644
--- a/app/behavior/engine.py
+++ b/app/behavior/engine.py
@@ -7,6 +7,8 @@ from zoneinfo import ZoneInfo
from app.actuators.models import (
ActuatorRecord,
+ AdaptiveWeightUpdate,
+ AutomationConflict,
BehaviorMode,
BehaviorPattern,
BehaviorPrediction,
@@ -14,9 +16,12 @@ from app.actuators.models import (
BehaviorStatus,
DecisionFactor,
ExecutionEvent,
+ ManualOverride,
+ ModelSnapshot,
RelatedAutomation,
SafetyProfile,
SafetyStage,
+ TimeProfile,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
@@ -168,6 +173,7 @@ class BehaviorEngine:
"eindeutig zugeordnete Handlungen fehlen."
)
)
+ model_version_id = f"model-{now.strftime('%Y%m%d%H%M%S')}"
behavior = record.behavior.model_copy(
update={
"status": status,
@@ -182,6 +188,18 @@ class BehaviorEngine:
"knowledge": _knowledge_lines(record, len(patterns), trusted_actions),
"assumptions": _assumption_lines(record),
"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)
@@ -454,6 +472,11 @@ class BehaviorEngine:
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
correct_count = record.behavior.correct_feedback_count
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(
update={
"patterns": patterns[-_MAX_PATTERNS:],
@@ -466,6 +489,39 @@ class BehaviorEngine:
"last_trained_at": now,
"correct_feedback_count": correct_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)
@@ -499,7 +555,10 @@ class BehaviorEngine:
)
]
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)
@@ -990,6 +1049,165 @@ def _uncertainty_lines(
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(
patterns: list[BehaviorPattern],
*,
diff --git a/app/main.py b/app/main.py
index e928229..d01b390 100644
--- a/app/main.py
+++ b/app/main.py
@@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
- version="1.1.0",
+ version="1.2.0",
lifespan=lifespan,
)
app.state.settings = load_settings()
diff --git a/app/static/index.html b/app/static/index.html
index 3e71763..e4b8067 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -903,6 +903,11 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const knowledge = record.behavior.knowledge || [];
const assumptions = record.behavior.assumptions || [];
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 = `
${Math.round((profile.confidence || 0) * 100)} % Profilklarheit ${escapeHtml(snapshot.sample_count)} Samples · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} % ${escapeHtml(snapshot.reason || "Kein Kommentar")} Noch kein Modell-Snapshot gespeichert.Sicherheit und manuelles Gegensteuern
@@ -962,6 +967,43 @@ async function showActuator(actuatorId, evaluationMessage = "") {
v1.2 Lernen, Rollback und Konflikte
+ Zeitprofile
+ Modell-Snapshots
+ Automatische Gewichtsanpassungen
+ ${adaptiveUpdates.length ? adaptiveUpdates.slice(-8).reverse().map(update => `
+
+ ${escapeHtml(update.entity_id)}: ${Math.round(update.previous_weight * 100)} % → ${Math.round(update.new_weight * 100)} %. ${escapeHtml(update.reason)}Automation-Konflikte
+ ${automationConflicts.length ? automationConflicts.map(conflict => `
+
+ ${escapeHtml(conflict.automation_entity_id)}: ${escapeHtml(conflict.status)} ${escapeHtml(conflict.reason)}
Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.
@@ -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) { const question = active ? pauseMatchingAutomations diff --git a/docs/V1_2_0_OPERATING_GUIDE.md b/docs/V1_2_0_OPERATING_GUIDE.md new file mode 100644 index 0000000..beca63a --- /dev/null +++ b/docs/V1_2_0_OPERATING_GUIDE.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ffbfff2..f3caa9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.1.0" +version = "1.2.0" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index 601b106..7b8247e 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -7,6 +7,7 @@ from pathlib import Path from fastapi.testclient import TestClient from app.actuators.lifecycle import ActuatorReconciliationService +from app.actuators.models import ModelSnapshot from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine from app.config import Settings @@ -113,6 +114,7 @@ def _install_service(tmp_path: Path) -> None: unit_of_measurement="lx", friendly_name="Abstellkammer Helligkeit", area_name="Abstellkammer", + state="12", ), HaEntitySummary( entity_id="binary_sensor.abstellkammer_motion", @@ -120,6 +122,7 @@ def _install_service(tmp_path: Path) -> None: device_class="motion", friendly_name="Abstellkammer Bewegung", area_name="Abstellkammer", + state="off", ), HaEntitySummary( 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 +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: with TestClient(app) as client: _install_service(tmp_path)