Compare commits

...

1 Commits

Author SHA1 Message Date
9419a9cd8c Add anomaly and performance monitoring
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:58:32 +02:00
10 changed files with 419 additions and 15 deletions

View File

@@ -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) [`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md)
- Version 1.2.0 adaptive Gewichtung, Rollback und Profile: - Version 1.2.0 adaptive Gewichtung, Rollback und Profile:
[`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md) [`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) - 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.2.0" version: "1.3.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

@@ -223,6 +223,16 @@ class AutomationConflict(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 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): class TimeProfile(BaseModel):
profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$") profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=80) 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) adaptive_weight_updates: list[AdaptiveWeightUpdate] = Field(default_factory=list)
automation_conflicts: list[AutomationConflict] = Field(default_factory=list) automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
time_profiles: list[TimeProfile] = Field(default_factory=list) time_profiles: list[TimeProfile] = Field(default_factory=list)
anomalies: list[AnomalyEvent] = Field(default_factory=list)
class ActuatorRecord(BaseModel): class ActuatorRecord(BaseModel):

View File

@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.actuators.lifecycle import ActuatorReconciliationService 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.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
@@ -89,6 +89,8 @@ class ActuatorSummary(BaseModel):
activation_ready: bool activation_ready: bool
activation_reason: str activation_reason: str
sample_count: int sample_count: int
anomaly_count: int = 0
critical_anomaly_count: int = 0
prediction_target_state: str | None = None prediction_target_state: str | None = None
prediction_confidence: float | None = None prediction_confidence: float | None = None
updated_at: str updated_at: str
@@ -108,6 +110,12 @@ class DashboardSystemStatus(BaseModel):
configured_actuators: int = 0 configured_actuators: int = 0
trained_models: int = 0 trained_models: int = 0
review_required: 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): class DashboardDiscoveryGroup(BaseModel):
@@ -124,6 +132,12 @@ class DashboardOverview(BaseModel):
jobs: JobQueueState = Field(default_factory=JobQueueState) 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]) @router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators( def discover_actuators(
request: Request, request: Request,
@@ -266,6 +280,14 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
activation_ready=record.behavior.activation_ready, activation_ready=record.behavior.activation_ready,
activation_reason=record.behavior.activation_reason, activation_reason=record.behavior.activation_reason,
sample_count=record.behavior.sample_count, 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=( prediction_target_state=(
record.behavior.prediction.target_state record.behavior.prediction.target_state
if record.behavior.prediction is not None if record.behavior.prediction is not None
@@ -305,6 +327,9 @@ def dashboard_overview(request: Request) -> DashboardOverview:
if isinstance(store, ActuatorStore) if isinstance(store, ActuatorStore)
else JobQueueState() 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( return DashboardOverview(
system=DashboardSystemStatus( system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"), websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -317,6 +342,11 @@ def dashboard_overview(request: Request) -> DashboardOverview:
configured_actuators=len(actuators), configured_actuators=len(actuators),
trained_models=reconciliation.trained_models, trained_models=reconciliation.trained_models,
review_required=reconciliation.review_required, 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( cache=EntityCacheStatus(
available=bool(raw_entities), 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]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()
@@ -635,6 +688,26 @@ def _finish_job(
store.finish_job(job.job_id, status=status, summary=summary, error=error) 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: def _service(request: Request) -> ActuatorReconciliationService:
service = getattr(request.app.state, "actuator_service", None) service = getattr(request.app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService): if not isinstance(service, ActuatorReconciliationService):

View File

@@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
from app.actuators.models import ( from app.actuators.models import (
ActuatorRecord, ActuatorRecord,
AdaptiveWeightUpdate, AdaptiveWeightUpdate,
AnomalyEvent,
AutomationConflict, AutomationConflict,
BehaviorMode, BehaviorMode,
BehaviorPattern, BehaviorPattern,
@@ -88,6 +89,16 @@ class BehaviorEngine:
), ),
"last_trained_at": now, "last_trained_at": now,
"reason": "Noch kein geeigneter Kontext für Verhaltenslernen vorhanden.", "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": [], "patterns": [],
"last_trained_at": now, "last_trained_at": now,
"reason": "Noch keine historischen Aktorhandlungen gefunden.", "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, reason,
), ),
"active_model_version": model_version_id, "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) return self._save_behavior(record, behavior)
@@ -326,6 +357,16 @@ class BehaviorEngine:
"assumptions": _assumption_lines(record), "assumptions": _assumption_lines(record),
"uncertainties": _uncertainty_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count), "uncertainties": _uncertainty_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count),
"safety_blockers": safety_blockers if prediction is not None else [], "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": ( "confidence_trend": (
[*record.behavior.confidence_trend, round(prediction.confidence, 4)][-30:] [*record.behavior.confidence_trend, round(prediction.confidence, 4)][-30:]
if prediction is not None if prediction is not None
@@ -493,6 +534,18 @@ class BehaviorEngine:
*record.behavior.adaptive_weight_updates, *record.behavior.adaptive_weight_updates,
*adaptive_updates, *adaptive_updates,
][-50:], ][-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 = ( record_for_save = (
@@ -560,6 +613,20 @@ class BehaviorEngine:
"automation_conflicts": _automation_conflicts(record, related), "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) return self._save_behavior(record, behavior)
def set_automation_enabled( def set_automation_enabled(
@@ -1208,6 +1275,115 @@ def _automation_conflicts(
return 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( 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.2.0", version="1.3.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -99,6 +99,8 @@
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; } .metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
.decision-list { display:grid; gap:8px; margin:10px 0; } .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 { 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; } .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 { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.actions button { flex:1 1 180px; margin-top:0; } .actions button { flex:1 1 180px; margin-top:0; }
@@ -283,7 +285,7 @@ let discoveryLoadPromise = null;
let currentSensorWeightGroups = []; let currentSensorWeightGroups = [];
const ACTUATOR_RESULT_LIMIT = 50; const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2000; const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 4500; const DASHBOARD_TIMEOUT_MS = 3000;
function jumpToSection(target) { function jumpToSection(target) {
if (!target) return; if (!target) return;
@@ -439,11 +441,17 @@ async function loadOverview() {
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>"; document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
try { try {
const dashboard = await apiWithTimeout("v1/actuators/dashboard", DASHBOARD_TIMEOUT_MS); const dashboard = await apiWithTimeout("v1/actuators/dashboard", DASHBOARD_TIMEOUT_MS);
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedActuators = dashboard.actuators || []; cachedActuators = dashboard.actuators || [];
cachedEntities = []; cachedEntities = [];
renderDashboardStatus(dashboard); renderDashboardStatus(dashboard);
renderConfiguredActuators(); 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) { } catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
if (budget) budget.textContent = "Startdaten verzögert"; if (budget) budget.textContent = "Startdaten verzögert";
@@ -504,15 +512,26 @@ function renderDashboardStatus(dashboard) {
).length; ).length;
const trainedCount = actuators.filter(record => record.behavior_status === "trained").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 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 jobs = dashboard.jobs?.jobs || [];
const runningJobs = jobs.filter(job => job.status === "running").length; 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 const cacheLabel = cache.available
? `Cache aktuell mit ${cache.entity_count} Entities` ? `Cache aktuell mit ${cache.entity_count} Entities`
: "Cache wird nach Discovery aufgebaut"; : "Cache wird nach Discovery aufgebaut";
status.innerHTML = ` status.innerHTML = `
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}"> <p class="${performanceClass}">
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.
</p> </p>
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}">WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")}</p>
<p class="muted">Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}</p> <p class="muted">Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}</p>
`; `;
chips.innerHTML = [ chips.innerHTML = [
@@ -522,6 +541,8 @@ function renderDashboardStatus(dashboard) {
`<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`, `<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`,
`<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`, `<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`,
`<span class="chip">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`, `<span class="chip">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`,
`<span class="chip">Anomalien: ${escapeHtml(anomalyTotal)}</span>`,
`<span class="chip">Kritisch: ${escapeHtml(criticalAnomalyTotal)}</span>`,
].join(""); ].join("");
stats.innerHTML = [ stats.innerHTML = [
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`, `<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`,
@@ -529,16 +550,20 @@ function renderDashboardStatus(dashboard) {
`<div class="metric"><strong>Aktiv / Shadow</strong>${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}</div>`, `<div class="metric"><strong>Aktiv / Shadow</strong>${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}</div>`,
`<div class="metric"><strong>Gelernt / Wartet</strong>${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}</div>`, `<div class="metric"><strong>Gelernt / Wartet</strong>${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}</div>`,
`<div class="metric"><strong>Gelernte Handlungen</strong>${escapeHtml(sampleTotal)}</div>`, `<div class="metric"><strong>Gelernte Handlungen</strong>${escapeHtml(sampleTotal)}</div>`,
`<div class="metric"><strong>Performance-Budget</strong>${escapeHtml(system.performance_budget_ms || 3000)} ms</div>`,
`<div class="metric"><strong>Job p95</strong>${escapeHtml(p95)}</div>`,
`<div class="metric"><strong>Langsame Jobs</strong>${escapeHtml(slowJobs)}</div>`,
`<div class="metric"><strong>Anomalien</strong>${escapeHtml(anomalyTotal)} offen</div>`,
`<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`, `<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`,
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`, `<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
].join(""); ].join("");
jobsBox.innerHTML = jobs.length ? ` jobsBox.innerHTML = jobs.length ? `
<h3>Job-Queue</h3> <h3>Job-Queue</h3>
${jobs.slice(-6).reverse().map(job => ` ${jobs.slice(-6).reverse().map(job => `
<div class="decision-row"> <div class="decision-row ${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? "slow" : ""}">
<header> <header>
<strong>${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong> <strong>${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong>
<span class="chip">${escapeHtml(job.status)}</span> <span class="chip">${escapeHtml(job.status)}${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? " · langsam" : ""}</span>
</header> </header>
<p class="muted">${escapeHtml(job.summary || "Keine Zusammenfassung")}</p> <p class="muted">${escapeHtml(job.summary || "Keine Zusammenfassung")}</p>
<p class="muted">Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p> <p class="muted">Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p>
@@ -908,6 +933,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const adaptiveUpdates = record.behavior.adaptive_weight_updates || []; const adaptiveUpdates = record.behavior.adaptive_weight_updates || [];
const automationConflicts = record.behavior.automation_conflicts || []; const automationConflicts = record.behavior.automation_conflicts || [];
const timeProfiles = record.behavior.time_profiles || []; const timeProfiles = record.behavior.time_profiles || [];
const anomalies = (record.behavior.anomalies || []).filter(item => !item.resolved);
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>
@@ -1004,6 +1030,23 @@ async function showActuator(actuatorId, evaluationMessage = "") {
`).join("") : "<li>Keine aktiven Automation-Konflikte erkannt.</li>"}</ul> `).join("") : "<li>Keine aktiven Automation-Konflikte erkannt.</li>"}</ul>
</details> </details>
`; `;
const anomalyPanel = `
<details class="manual-context" ${anomalies.length ? "open" : ""}>
<summary>v1.3 Anomalie- und Performance-Hinweise</summary>
<div class="decision-list">
${anomalies.length ? anomalies.map(anomaly => `
<div class="decision-row ${anomaly.severity === "critical" ? "critical" : ""}">
<header>
<strong>${escapeHtml(anomaly.title)}</strong>
<span class="chip">${escapeHtml(anomaly.severity)} · ${escapeHtml(anomaly.category)}</span>
</header>
<p>${escapeHtml(anomaly.detail)}</p>
<p class="muted">Erkannt: ${escapeHtml(anomaly.detected_at || "offen")}</p>
</div>
`).join("") : "<p class='ok'>Keine offenen Anomalien fuer diesen Aktor.</p>"}
</div>
</details>
`;
const learnedAutomationActions = record.behavior.patterns.filter( const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation", pattern => pattern.source === "automation",
).length; ).length;
@@ -1116,6 +1159,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${safetyControls} ${safetyControls}
${decisionArchive} ${decisionArchive}
${adaptivePanel} ${adaptivePanel}
${anomalyPanel}
<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>

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "1.2.0" version = "1.3.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

@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
from time import perf_counter from time import perf_counter
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from pathlib import Path 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.models import JobStatus, 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
@@ -407,7 +407,7 @@ def test_reconciliation_run_records_visible_job_queue(tmp_path: Path) -> None:
assert payload["jobs"][-1]["status"] == "completed" 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: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
client.get("/v1/actuators/discovery") 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 root_response.status_code == 200
assert dashboard_response.status_code == 200 assert dashboard_response.status_code == 200
assert root_elapsed < 5.0 assert root_elapsed < 3.0
assert dashboard_elapsed < 5.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: def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None: