diff --git a/README.md b/README.md index 950d49d..9092b2f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ nach einer ausdrücklichen Freigabe ausführen. [`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) +- Version 1.3.0 Anomalie- und Performance-Überwachung: + [`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_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 91d3f3a..03c01f1 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.2.0" +version: "1.3.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 ada424e..0d52729 100644 --- a/app/actuators/models.py +++ b/app/actuators/models.py @@ -223,6 +223,16 @@ class AutomationConflict(BaseModel): updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) +class AnomalyEvent(BaseModel): + anomaly_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$") + severity: str = Field(default="info", max_length=20) + category: str = Field(max_length=40) + title: str = Field(min_length=1, max_length=160) + detail: str = Field(min_length=1, max_length=500) + detected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + resolved: bool = False + + class TimeProfile(BaseModel): profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$") label: str = Field(min_length=1, max_length=80) @@ -270,6 +280,7 @@ class BehaviorState(BaseModel): 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) + anomalies: list[AnomalyEvent] = Field(default_factory=list) class ActuatorRecord(BaseModel): diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 6a8b779..5c556cb 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, Field from app.actuators.lifecycle import ActuatorReconciliationService -from app.actuators.models import ActuatorRecord, ReconciliationState, SensorWeightGroup +from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine @@ -89,6 +89,8 @@ class ActuatorSummary(BaseModel): activation_ready: bool activation_reason: str sample_count: int + anomaly_count: int = 0 + critical_anomaly_count: int = 0 prediction_target_state: str | None = None prediction_confidence: float | None = None updated_at: str @@ -108,6 +110,12 @@ class DashboardSystemStatus(BaseModel): configured_actuators: int = 0 trained_models: int = 0 review_required: int = 0 + performance_budget_ms: int = 3000 + job_p95_duration_ms: int | None = None + slow_job_count: int = 0 + performance_status: str = "unknown" + anomaly_count: int = 0 + critical_anomaly_count: int = 0 class DashboardDiscoveryGroup(BaseModel): @@ -124,6 +132,12 @@ class DashboardOverview(BaseModel): jobs: JobQueueState = Field(default_factory=JobQueueState) +class AnomalyOverview(BaseModel): + actuator_entity_id: str + friendly_name: str | None = None + anomalies: list[AnomalyEvent] = Field(default_factory=list) + + @router.get("/discovery", response_model=list[HaEntitySummary]) def discover_actuators( request: Request, @@ -266,6 +280,14 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]: activation_ready=record.behavior.activation_ready, activation_reason=record.behavior.activation_reason, sample_count=record.behavior.sample_count, + anomaly_count=len([item for item in record.behavior.anomalies if not item.resolved]), + critical_anomaly_count=len( + [ + item + for item in record.behavior.anomalies + if not item.resolved and item.severity == "critical" + ] + ), prediction_target_state=( record.behavior.prediction.target_state if record.behavior.prediction is not None @@ -305,6 +327,9 @@ def dashboard_overview(request: Request) -> DashboardOverview: if isinstance(store, ActuatorStore) else JobQueueState() ) + job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs) + anomaly_count = sum(record.anomaly_count for record in actuators) + critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators) return DashboardOverview( system=DashboardSystemStatus( websocket_status=getattr(ws_status, "status", "unavailable"), @@ -317,6 +342,11 @@ def dashboard_overview(request: Request) -> DashboardOverview: configured_actuators=len(actuators), trained_models=reconciliation.trained_models, review_required=reconciliation.review_required, + job_p95_duration_ms=job_p95_duration_ms, + slow_job_count=slow_job_count, + performance_status=performance_status, + anomaly_count=anomaly_count, + critical_anomaly_count=critical_anomaly_count, ), cache=EntityCacheStatus( available=bool(raw_entities), @@ -329,6 +359,29 @@ def dashboard_overview(request: Request) -> DashboardOverview: ) +@router.get("/anomalies", response_model=list[AnomalyOverview]) +def list_anomalies(request: Request) -> list[AnomalyOverview]: + records = _service(request).list_configured() + entity_map = _load_cached_entity_map( + request, + {record.actuator_entity_id for record in records}, + ) + overview: list[AnomalyOverview] = [] + for record in records: + active = [item for item in record.behavior.anomalies if not item.resolved] + if not active: + continue + entity = entity_map.get(record.actuator_entity_id) + overview.append( + AnomalyOverview( + actuator_entity_id=record.actuator_entity_id, + friendly_name=entity.friendly_name if entity is not None else None, + anomalies=active, + ) + ) + return overview + + @router.get("", response_model=list[ActuatorRecord]) def list_configured(request: Request) -> list[ActuatorRecord]: return _service(request).list_configured() @@ -635,6 +688,26 @@ def _finish_job( store.finish_job(job.job_id, status=status, summary=summary, error=error) +def _performance_status(jobs: JobQueueState) -> tuple[int | None, int, str]: + budget_ms = 3000 + durations = sorted( + job.duration_ms + for job in jobs.jobs + if job.status is JobStatus.COMPLETED and job.duration_ms is not None + ) + slow_count = sum(1 for duration in durations if duration >= budget_ms) + if durations: + index = min(len(durations) - 1, int(round((len(durations) - 1) * 0.95))) + p95: int | None = durations[index] + status_value = "slow" if slow_count else "ok" + else: + p95 = None + status_value = "unknown" + if any(job.status is JobStatus.RUNNING for job in jobs.jobs): + status_value = "running" if status_value == "unknown" else status_value + return p95, slow_count, status_value + + def _service(request: Request) -> ActuatorReconciliationService: service = getattr(request.app.state, "actuator_service", None) if not isinstance(service, ActuatorReconciliationService): diff --git a/app/behavior/engine.py b/app/behavior/engine.py index e0ed2e3..7680594 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo from app.actuators.models import ( ActuatorRecord, AdaptiveWeightUpdate, + AnomalyEvent, AutomationConflict, BehaviorMode, BehaviorPattern, @@ -88,6 +89,16 @@ class BehaviorEngine: ), "last_trained_at": now, "reason": "Noch kein geeigneter Kontext für Verhaltenslernen vorhanden.", + "anomalies": _detect_anomalies( + record, + now=now, + min_behavior_actions=self._settings.min_behavior_actions, + stale_hours=self._settings.retrain_stale_hours, + sample_count=0, + trusted_actions=0, + prediction=None, + safety_blockers=[], + ), } ), ) @@ -128,6 +139,16 @@ class BehaviorEngine: "patterns": [], "last_trained_at": now, "reason": "Noch keine historischen Aktorhandlungen gefunden.", + "anomalies": _detect_anomalies( + record, + now=now, + min_behavior_actions=self._settings.min_behavior_actions, + stale_hours=self._settings.retrain_stale_hours, + sample_count=0, + trusted_actions=0, + prediction=None, + safety_blockers=[], + ), } ), ) @@ -200,6 +221,16 @@ class BehaviorEngine: reason, ), "active_model_version": model_version_id, + "anomalies": _detect_anomalies( + record, + now=now, + min_behavior_actions=self._settings.min_behavior_actions, + stale_hours=self._settings.retrain_stale_hours, + sample_count=len(patterns), + trusted_actions=trusted_actions, + prediction=record.behavior.prediction, + safety_blockers=record.behavior.safety_blockers, + ), } ) return self._save_behavior(record, behavior) @@ -326,6 +357,16 @@ class BehaviorEngine: "assumptions": _assumption_lines(record), "uncertainties": _uncertainty_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count), "safety_blockers": safety_blockers if prediction is not None else [], + "anomalies": _detect_anomalies( + record, + now=now, + min_behavior_actions=self._settings.min_behavior_actions, + stale_hours=self._settings.retrain_stale_hours, + sample_count=record.behavior.sample_count, + trusted_actions=record.behavior.high_confidence_sample_count, + prediction=prediction, + safety_blockers=safety_blockers if prediction is not None else [], + ), "confidence_trend": ( [*record.behavior.confidence_trend, round(prediction.confidence, 4)][-30:] if prediction is not None @@ -493,6 +534,18 @@ class BehaviorEngine: *record.behavior.adaptive_weight_updates, *adaptive_updates, ][-50:], + "anomalies": _detect_anomalies( + record, + now=now, + min_behavior_actions=self._settings.min_behavior_actions, + stale_hours=self._settings.retrain_stale_hours, + sample_count=len(patterns), + trusted_actions=record.behavior.high_confidence_sample_count, + prediction=prediction, + safety_blockers=record.behavior.safety_blockers, + correct_feedback_count=correct_count, + incorrect_feedback_count=incorrect_count, + ), } ) record_for_save = ( @@ -560,6 +613,20 @@ class BehaviorEngine: "automation_conflicts": _automation_conflicts(record, related), } ) + behavior = behavior.model_copy( + update={ + "anomalies": _detect_anomalies( + record.model_copy(update={"behavior": behavior}), + now=datetime.now(timezone.utc), + min_behavior_actions=self._settings.min_behavior_actions, + stale_hours=self._settings.retrain_stale_hours, + sample_count=behavior.sample_count, + trusted_actions=behavior.high_confidence_sample_count, + prediction=behavior.prediction, + safety_blockers=behavior.safety_blockers, + ) + } + ) return self._save_behavior(record, behavior) def set_automation_enabled( @@ -1208,6 +1275,115 @@ def _automation_conflicts( return conflicts +def _detect_anomalies( + record: ActuatorRecord, + *, + now: datetime, + min_behavior_actions: int, + stale_hours: int, + sample_count: int, + trusted_actions: int, + prediction: BehaviorPrediction | None, + safety_blockers: list[str], + correct_feedback_count: int | None = None, + incorrect_feedback_count: int | None = None, +) -> list[AnomalyEvent]: + anomalies: list[AnomalyEvent] = [] + + def add(category: str, severity: str, title: str, detail: str) -> None: + anomalies.append( + AnomalyEvent( + anomaly_id=f"{record.actuator_entity_id}.{category}", + category=category, + severity=severity, + title=title, + detail=detail, + detected_at=now, + ) + ) + + if not record.assignment.selected_context_entity_ids and not record.assignment.selected_numeric_entity_id: + add( + "missing_context", + "warning", + "Kein Kontext verbunden", + "Der Aktor hat keine Sensor-/Kontextbasis. Entscheidungen bleiben unsicher.", + ) + if sample_count < min_behavior_actions: + add( + "low_samples", + "info", + "Zu wenig Lernbeispiele", + f"{sample_count} von {min_behavior_actions} benoetigten Handlungen gelernt.", + ) + if trusted_actions < sample_count: + add( + "unclear_sources", + "info", + "Unklare Aktorhandlungen", + "Ein Teil der gelernten Handlungen stammt nicht eindeutig von Nutzer oder Automation.", + ) + if record.behavior.last_trained_at is not None: + age = now - record.behavior.last_trained_at + if age > timedelta(hours=stale_hours): + add( + "stale_training", + "warning", + "Training ist veraltet", + f"Letztes Training liegt mehr als {stale_hours} Stunden zurueck.", + ) + if prediction is not None and prediction.matching_patterns and prediction.confidence < record.behavior.safety.min_confidence: + add( + "low_confidence_prediction", + "warning", + "Vorhersage unter Sicherheitsgrenze", + ( + f"Confidence {prediction.confidence:.0%} liegt unter " + f"{record.behavior.safety.min_confidence:.0%}." + ), + ) + if record.behavior.safety.manual_block: + add( + "manual_block", + "info", + "Manuelle Sicherheitssperre aktiv", + "Der Aktor ist bewusst gegen automatisches Schalten gesperrt.", + ) + if safety_blockers: + add( + "safety_blockers", + "info", + "Safety blockiert aktuelle Aktion", + " ".join(safety_blockers)[:500], + ) + if any(conflict.severity == "warning" for conflict in record.behavior.automation_conflicts): + add( + "automation_conflict", + "critical", + "Parallele Automation erkannt", + "SillyHome und mindestens eine passende HA-Automation koennen parallel schalten.", + ) + correct = ( + record.behavior.correct_feedback_count + if correct_feedback_count is None + else correct_feedback_count + ) + incorrect = ( + record.behavior.incorrect_feedback_count + if incorrect_feedback_count is None + else incorrect_feedback_count + ) + total = correct + incorrect + if total >= 3 and incorrect / total >= 0.35: + add( + "feedback_error_rate", + "critical", + "Viele falsche Vorhersagen", + f"{incorrect} von {total} Feedbacks waren negativ. Modell pruefen oder Rollback nutzen.", + ) + return anomalies[-30:] + + def predict_behavior( patterns: list[BehaviorPattern], *, diff --git a/app/main.py b/app/main.py index d01b390..30970b9 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.2.0", + version="1.3.0", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index e4b8067..6b28fc1 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -99,6 +99,8 @@ .metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; } .decision-list { display:grid; gap:8px; margin:10px 0; } .decision-row { background:#121922; border:1px solid var(--border); border-radius:8px; padding:9px; min-width:0; overflow-wrap:anywhere; } + .decision-row.slow, + .decision-row.critical { border-color:var(--warn); box-shadow:0 0 0 1px rgba(243,201,105,.25); } .decision-row header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; } .actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; } .actions button { flex:1 1 180px; margin-top:0; } @@ -283,7 +285,7 @@ let discoveryLoadPromise = null; let currentSensorWeightGroups = []; const ACTUATOR_RESULT_LIMIT = 50; const STATUS_TIMEOUT_MS = 2000; -const DASHBOARD_TIMEOUT_MS = 4500; +const DASHBOARD_TIMEOUT_MS = 3000; function jumpToSection(target) { if (!target) return; @@ -439,11 +441,17 @@ async function loadOverview() { document.getElementById("configured-actuators").innerHTML = "

Beobachtete Geräte werden geladen ...

"; try { const dashboard = await apiWithTimeout("v1/actuators/dashboard", DASHBOARD_TIMEOUT_MS); + dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); cachedActuators = dashboard.actuators || []; cachedEntities = []; renderDashboardStatus(dashboard); renderConfiguredActuators(); - if (budget) budget.textContent = `Bereit in ${Math.round(performance.now() - startedAt)} ms`; + if (budget) { + const loadMs = dashboard._load_elapsed_ms; + budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS + ? `Bereit in ${loadMs} ms` + : `Langsam: ${loadMs} ms`; + } } catch (error) { document.getElementById("configured-actuators").innerHTML = `

${escapeHtml(error.message)}

`; if (budget) budget.textContent = "Startdaten verzögert"; @@ -504,15 +512,26 @@ function renderDashboardStatus(dashboard) { ).length; const trainedCount = actuators.filter(record => record.behavior_status === "trained").length; const sampleTotal = actuators.reduce((sum, record) => sum + Number(record.sample_count || 0), 0); + const anomalyTotal = Number(system.anomaly_count || 0); + const criticalAnomalyTotal = Number(system.critical_anomaly_count || 0); + const loadMs = Number(dashboard._load_elapsed_ms || 0); const jobs = dashboard.jobs?.jobs || []; const runningJobs = jobs.filter(job => job.status === "running").length; + const slowJobs = Number(system.slow_job_count || 0); + const p95 = system.job_p95_duration_ms == null ? "offen" : `${system.job_p95_duration_ms} ms`; + const performanceClass = ( + loadMs > DASHBOARD_TIMEOUT_MS + || slowJobs > 0 + || system.performance_status === "slow" + ) ? "warn" : "ok"; const cacheLabel = cache.available ? `Cache aktuell mit ${cache.entity_count} Entities` : "Cache wird nach Discovery aufgebaut"; status.innerHTML = ` -

- Dashboard bereit. WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")} +

+ Dashboard bereit in ${escapeHtml(loadMs || "offen")} ms. Budget: ${escapeHtml(system.performance_budget_ms || DASHBOARD_TIMEOUT_MS)} ms.

+

WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")}

Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}

`; chips.innerHTML = [ @@ -522,6 +541,8 @@ function renderDashboardStatus(dashboard) { `Lernbereit: ${escapeHtml(system.trained_models ?? 0)}`, `Prüfen: ${escapeHtml(system.review_required ?? 0)}`, `Jobs aktiv: ${escapeHtml(runningJobs)}`, + `Anomalien: ${escapeHtml(anomalyTotal)}`, + `Kritisch: ${escapeHtml(criticalAnomalyTotal)}`, ].join(""); stats.innerHTML = [ `
Geladene Startdaten${escapeHtml(actuators.length)} Geräte
`, @@ -529,16 +550,20 @@ function renderDashboardStatus(dashboard) { `
Aktiv / Shadow${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}
`, `
Gelernt / Wartet${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}
`, `
Gelernte Handlungen${escapeHtml(sampleTotal)}
`, + `
Performance-Budget${escapeHtml(system.performance_budget_ms || 3000)} ms
`, + `
Job p95${escapeHtml(p95)}
`, + `
Langsame Jobs${escapeHtml(slowJobs)}
`, + `
Anomalien${escapeHtml(anomalyTotal)} offen
`, `
Discovery-Gruppen${escapeHtml(discoveryGroups.length)} Kategorien
`, `
Cache-Zeitpunkt${escapeHtml(cache.updated_at || "noch offen")}
`, ].join(""); jobsBox.innerHTML = jobs.length ? `

Job-Queue

${jobs.slice(-6).reverse().map(job => ` -
+
${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""} - ${escapeHtml(job.status)} + ${escapeHtml(job.status)}${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? " · langsam" : ""}

${escapeHtml(job.summary || "Keine Zusammenfassung")}

Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}

@@ -908,6 +933,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { const adaptiveUpdates = record.behavior.adaptive_weight_updates || []; const automationConflicts = record.behavior.automation_conflicts || []; const timeProfiles = record.behavior.time_profiles || []; + const anomalies = (record.behavior.anomalies || []).filter(item => !item.resolved); const safetyControls = `
Sicherheit und manuelles Gegensteuern @@ -1004,6 +1030,23 @@ async function showActuator(actuatorId, evaluationMessage = "") { `).join("") : "
  • Keine aktiven Automation-Konflikte erkannt.
  • "}
    `; + const anomalyPanel = ` +
    + v1.3 Anomalie- und Performance-Hinweise +
    + ${anomalies.length ? anomalies.map(anomaly => ` +
    +
    + ${escapeHtml(anomaly.title)} + ${escapeHtml(anomaly.severity)} · ${escapeHtml(anomaly.category)} +
    +

    ${escapeHtml(anomaly.detail)}

    +

    Erkannt: ${escapeHtml(anomaly.detected_at || "offen")}

    +
    + `).join("") : "

    Keine offenen Anomalien fuer diesen Aktor.

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

    Passende Home-Assistant-Automationen

    Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.

    diff --git a/docs/V1_3_0_OPERATING_GUIDE.md b/docs/V1_3_0_OPERATING_GUIDE.md new file mode 100644 index 0000000..024a181 --- /dev/null +++ b/docs/V1_3_0_OPERATING_GUIDE.md @@ -0,0 +1,68 @@ +# SillyHome Next v1.3.0 Operating Guide + +v1.3.0 ergänzt die v1.2-Lernfunktionen um Anomalie-Erkennung und +Performance-Überwachung. Das Dashboard bleibt Visualisierung und Einrichtung; +der direkte Schaltpfad bleibt kurz und führt vor dem Home-Assistant-Service-Call +keine Discovery, kein Training und keine Modellanalyse aus. + +## Performance-Budget + +- Dashboard-Start und `/v1/actuators/dashboard` haben ein Budget von 3000 ms. +- Das Dashboard zeigt die eigene Ladezeit, das aktive Budget, Job-p95 und die + Anzahl langsamer Jobs. +- Jobs ab 3000 ms werden in der Job-Queue als langsam markiert. +- Der automatisierte API-Test prüft den Root- und Dashboard-Startpfad gegen das + 3-Sekunden-Budget. + +## Anomalie-Erkennung + +Anomalien werden pro Aktor gespeichert und im Aktor-Detail angezeigt. Erkannt +werden aktuell: + +- fehlender Sensor-/Kontextbezug +- zu wenige Lernbeispiele +- unklare Quellen historischer Schaltungen +- veraltetes Training +- Vorhersagen unter der Sicherheitsgrenze +- aktive manuelle Sicherheitssperren +- Safety-Blocker +- parallele HA-Automationen bei aktivem SillyHome +- hohe negative Feedbackquote + +Die Anomalien sind Hinweise für Setup und manuelles Gegensteuern. Sie lösen +keine automatische Eskalation und keine langsamere Schaltung aus. + +## API + +- `GET /v1/actuators/dashboard` liefert jetzt zusätzlich: + - `performance_budget_ms` + - `job_p95_duration_ms` + - `slow_job_count` + - `performance_status` + - `anomaly_count` + - `critical_anomaly_count` +- `GET /v1/actuators/anomalies` liefert offene Anomalien gruppiert nach Aktor. + +## Betrieb + +Bei Ladezeiten ab 3 Sekunden gilt die Seite als nicht performant. Dann zuerst +prüfen: + +1. Dashboard-Statistik: Ladezeit, Job-p95, langsame Jobs. +2. Job-Queue: welche Aktion langsam war. +3. Aktor-Detail: Anomalien, Safety-Blocker und Automation-Konflikte. +4. Falls Discovery oder Training langsam war: nicht in den Startpfad ziehen, + sondern geplant, manuell oder über Queue laufen lassen. + +## Qualität + +Vor Release/Installation ausführen: + +```bash +pytest -q +ruff check . +mypy app backend tests +git diff --check +``` + +Zusätzlich das eingebettete Dashboard-JavaScript mit `node --check` prüfen. diff --git a/pyproject.toml b/pyproject.toml index f3caa9c..6aba4e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.2.0" +version = "1.3.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 7b8247e..5b42a01 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -1,13 +1,13 @@ from __future__ import annotations from time import perf_counter -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from fastapi.testclient import TestClient from app.actuators.lifecycle import ActuatorReconciliationService -from app.actuators.models import ModelSnapshot +from app.actuators.models import JobStatus, ModelSnapshot from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine from app.config import Settings @@ -407,7 +407,7 @@ def test_reconciliation_run_records_visible_job_queue(tmp_path: Path) -> None: assert payload["jobs"][-1]["status"] == "completed" -def test_dashboard_start_path_stays_within_five_second_budget(tmp_path: Path) -> None: +def test_dashboard_start_path_stays_within_three_second_budget(tmp_path: Path) -> None: with TestClient(app) as client: _install_service(tmp_path) client.get("/v1/actuators/discovery") @@ -423,8 +423,38 @@ def test_dashboard_start_path_stays_within_five_second_budget(tmp_path: Path) -> assert root_response.status_code == 200 assert dashboard_response.status_code == 200 - assert root_elapsed < 5.0 - assert dashboard_elapsed < 5.0 + assert root_elapsed < 3.0 + assert dashboard_elapsed < 3.0 + + +def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + client.get("/v1/actuators/discovery") + client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) + store = app.state.actuator_store + job = store.start_job(kind="training", trigger="test", summary="Langsamer Testjob") + queue = store.load_job_queue() + queue.jobs = [ + item.model_copy(update={"started_at": datetime.now(timezone.utc) - timedelta(seconds=4)}) + if item.job_id == job.job_id + else item + for item in queue.jobs + ] + store._persist_job_queue(queue) + store.finish_job(job.job_id, status=JobStatus.COMPLETED, summary="Fertig") + + dashboard_response = client.get("/v1/actuators/dashboard") + anomalies_response = client.get("/v1/actuators/anomalies") + + assert dashboard_response.status_code == 200 + system = dashboard_response.json()["system"] + assert system["performance_budget_ms"] == 3000 + assert system["slow_job_count"] == 1 + assert system["performance_status"] == "slow" + assert system["anomaly_count"] >= 1 + assert anomalies_response.status_code == 200 + assert anomalies_response.json() def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None: