From 2ec2c64cba99f17744bfc328730946e460b62d16 Mon Sep 17 00:00:00 2001 From: Otto Date: Wed, 17 Jun 2026 18:41:03 +0200 Subject: [PATCH] Add adaptive learning and model rollback --- CHANGELOG.md | 12 ++ README.md | 2 + addon/config.yaml | 2 +- app/actuators/models.py | 40 ++++++ app/api/v1/actuators.py | 18 +++ app/behavior/engine.py | 220 ++++++++++++++++++++++++++++++++- app/main.py | 2 +- app/static/index.html | 58 +++++++++ docs/V1_2_0_OPERATING_GUIDE.md | 62 ++++++++++ pyproject.toml | 2 +- tests/api/test_actuators.py | 51 ++++++++ 11 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 docs/V1_2_0_OPERATING_GUIDE.md 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 = `
Sicherheit und manuelles Gegensteuern @@ -962,6 +967,43 @@ async function showActuator(actuatorId, evaluationMessage = "") {
`; + const adaptivePanel = ` +
+ v1.2 Lernen, Rollback und Konflikte +

Zeitprofile

+
+ ${timeProfiles.length ? timeProfiles.map(profile => ` +
+ ${escapeHtml(profile.label)} + ${escapeHtml(profile.sample_count)} Samples · ${escapeHtml(profile.dominant_state || "offen")} +

${Math.round((profile.confidence || 0) * 100)} % Profilklarheit

+
+ `).join("") : "
ZeitprofileNoch keine Daten
"} +
+

Modell-Snapshots

+
+ ${snapshots.length ? snapshots.slice(-5).reverse().map(snapshot => ` +
+
+ ${escapeHtml(snapshot.version_id)} + ${snapshot.version_id === activeModelVersion ? "aktiv" : "Rollback möglich"} +
+

${escapeHtml(snapshot.sample_count)} Samples · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %

+

${escapeHtml(snapshot.reason || "Kein Kommentar")}

+ ${snapshot.version_id !== activeModelVersion ? `` : ""} +
+ `).join("") : "

Noch kein Modell-Snapshot gespeichert.

"} +
+

Automatische Gewichtsanpassungen

+ +

Automation-Konflikte

+ +
+ `; const learnedAutomationActions = record.behavior.patterns.filter( pattern => pattern.source === "automation", ).length; @@ -1073,6 +1115,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { ${safetyControls} ${decisionArchive} + ${adaptivePanel}

Passende Home-Assistant-Automationen

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)