Compare commits

...

3 Commits

Author SHA1 Message Date
0101596e93 Add safety dashboard and decision transparency
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:26:49 +02:00
ca253d1e6c Fix dashboard text overflow and close v1 docs gaps
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 11:53:25 +02:00
b9b5def7bb Add actuator sensor weighting controls
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 11:41:46 +02:00
15 changed files with 1181 additions and 45 deletions

View File

@@ -1,5 +1,38 @@
# Changelog
## 1.1.0 - 2026-06-17
- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert:
Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/
Unsicherheiten und Beitragsfaktoren pro Aktor.
- Lokales Safety-Profil pro Aktor eingefuehrt: manuelle Sperre,
Freigabestufe, Mindest-Confidence und optionaler Cooldown werden vor
autonomem Schalten ausgewertet.
- Sofort-Schaltpfad bleibt schnell: Safety prueft nur lokale Daten; der
Home-Assistant-Serviceaufruf wird nicht durch Discovery, Training oder
Statistik blockiert.
- Sichtbare Job-Queue fuer Discovery, Reconciliation, Training, Evaluation
und Automation-Refresh mit Status, Dauer, Fehler und Zusammenfassung.
- Entscheidungsstatistik erweitert: Sensor-/Kontextfaktoren, aktive
Gewichtungen, Sample-/Confidence-Trends und Feedbackzaehler werden
persistiert.
## 1.0.5 - 2026-06-17
- Lange Friendly Names, Entity-IDs, Chips, Tabellenwerte und Metriken brechen
im Dashboard responsiv um und laufen nicht mehr aus Karten oder Boxen.
- Automatisierter Performance-Budget-Test fuer Root-HTML und
`/v1/actuators/dashboard` gegen das 5-Sekunden-Limit ergaenzt.
- HA-/Ingress-Verifikation mit Supervisor-Status, Backup, Watchdog,
Hard-Reload und Rollback im Operating Guide dokumentiert.
## 1.0.4 - 2026-06-17
- Sensor-Relevanz ist in der Aktor-Detailansicht sichtbar: automatische
Relevanz, aktive Gewichtung und Score werden pro verwendetem Sensor/Zustand
angezeigt.
- Gewichtungen koennen im Dashboard korrigiert und per API unter
`/v1/actuators/{actuator_entity_id}/weights` gespeichert werden.
- Gruppen-Gewichtungen buendeln mehrere Sensoren/Zustaende fuer einen Aktor,
damit verbundene Kontextsignale gemeinsam bewertet werden koennen.
## 1.0.3 - 2026-06-17
- Header-Menue als Pulldown umgesetzt; die separate Navigationsleiste entfaellt.
- Geraetegruppen und manuelle Kontextbereiche sind standardmaessig geschlossen.

View File

@@ -15,6 +15,8 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_0_0_OPERATING_GUIDE.md`](docs/V1_0_0_OPERATING_GUIDE.md)
- Version 1.0.x Abnahme und offene Punkte:
[`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)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad

View File

@@ -1,5 +1,5 @@
name: SillyHome Next
version: "1.0.3"
version: "1.1.0"
slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -16,6 +16,7 @@ from app.actuators.models import (
ManualOverride,
ModelLifecycleState,
ReconciliationState,
SensorWeightGroup,
model_id_for_actuator,
)
from app.actuators.store import ActuatorStore
@@ -239,6 +240,10 @@ class ActuatorReconciliationService:
override = ManualOverride(
numeric_entity_id=numeric_entity_id,
context_entity_ids=selected_context_ids,
sensor_weights=record.manual_override.sensor_weights if record.manual_override else {},
sensor_weight_groups=(
record.manual_override.sensor_weight_groups if record.manual_override else []
),
updated_at=now,
note=note,
)
@@ -253,17 +258,23 @@ class ActuatorReconciliationService:
update={
"assignment": assignment,
"manual_override": override,
"numeric_candidates": _merge_manual_candidates(
record.numeric_candidates,
entities,
[numeric_entity_id] if numeric_entity_id else [],
role=EntityRole.MEASUREMENT,
"numeric_candidates": _apply_weight_overrides(
_merge_manual_candidates(
record.numeric_candidates,
entities,
[numeric_entity_id] if numeric_entity_id else [],
role=EntityRole.MEASUREMENT,
),
override,
),
"context_candidates": _merge_manual_candidates(
record.context_candidates,
entities,
selected_context_ids,
role=EntityRole.CONTEXT,
"context_candidates": _apply_weight_overrides(
_merge_manual_candidates(
record.context_candidates,
entities,
selected_context_ids,
role=EntityRole.CONTEXT,
),
override,
),
"lifecycle": lifecycle,
"updated_at": now,
@@ -271,6 +282,65 @@ class ActuatorReconciliationService:
)
return self._store.upsert(updated)
def set_weight_overrides(
self,
actuator_entity_id: str,
*,
sensor_weights: dict[str, float],
sensor_weight_groups: list[SensorWeightGroup],
note: str | None = None,
) -> ActuatorRecord:
now = datetime.now(timezone.utc)
record = self._store.get(actuator_entity_id)
selected_ids = {
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
}
selected_ids.update(sensor_weights)
for group in sensor_weight_groups:
selected_ids.update(group.entity_ids)
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
missing = [entity_id for entity_id in selected_ids if entity_id not in entities]
if missing:
raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(sorted(missing))}")
previous = record.manual_override
override = 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={entity_id: round(weight, 4) for entity_id, weight in sensor_weights.items()},
sensor_weight_groups=sensor_weight_groups,
updated_at=now,
note=note,
)
updated = record.model_copy(
update={
"manual_override": override,
"numeric_candidates": _apply_weight_overrides(
record.numeric_candidates,
override,
),
"context_candidates": _apply_weight_overrides(
record.context_candidates,
override,
),
"updated_at": now,
}
)
return self._store.upsert(updated)
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
state = self._store.load_reconciliation_state().model_copy(
update={
@@ -376,6 +446,9 @@ class ActuatorReconciliationService:
),
context=True,
)
if record.manual_override is not None:
numeric_candidates = _apply_weight_overrides(numeric_candidates, record.manual_override)
context_candidates = _apply_weight_overrides(context_candidates, record.manual_override)
assignment = (
self._manual_assignment(record.manual_override)
if record.manual_override is not None
@@ -918,6 +991,41 @@ def _merge_manual_candidates(
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
def _apply_weight_overrides(
candidates: list[AssignmentCandidate],
override: ManualOverride,
) -> list[AssignmentCandidate]:
if not override.sensor_weights and not override.sensor_weight_groups:
return candidates
group_weights: dict[str, float] = {}
for group in override.sensor_weight_groups:
for entity_id in group.entity_ids:
group_weights[entity_id] = max(group_weights.get(entity_id, 0.0), group.weight)
weighted: list[AssignmentCandidate] = []
for candidate in candidates:
explicit = override.sensor_weights.get(candidate.entity_id)
group_weight = group_weights.get(candidate.entity_id)
manual_weight = explicit if explicit is not None else group_weight
effective_weight = manual_weight if manual_weight is not None else 1.0
evidence = [
item
for item in candidate.evidence
if not item.startswith("Manuelle Gewichtung:")
]
if manual_weight is not None:
evidence.append(f"Manuelle Gewichtung: {round(manual_weight * 100)} %.")
weighted.append(
candidate.model_copy(
update={
"manual_weight": manual_weight,
"effective_weight": round(effective_weight, 4),
"evidence": evidence,
}
)
)
return sorted(weighted, key=lambda item: (-item.confidence * item.effective_weight, item.entity_id))
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context:
mapping = {

View File

@@ -37,6 +37,21 @@ class BehaviorStatus(StrEnum):
BLOCKED = "blocked"
class SafetyStage(StrEnum):
OBSERVE = "observe"
SUGGEST = "suggest"
SHADOW = "shadow"
PARTIAL = "partial"
ACTIVE = "active"
class JobStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class AssignmentCandidate(BaseModel):
entity_id: str
domain: str
@@ -49,6 +64,8 @@ class AssignmentCandidate(BaseModel):
device_name: str | None = None
score: float = Field(ge=0.0)
confidence: float = Field(ge=0.0, le=1.0)
manual_weight: float | None = Field(default=None, ge=0.0, le=1.0)
effective_weight: float = Field(default=1.0, ge=0.0, le=1.0)
auto_accepted: bool = False
evidence: list[str] = Field(default_factory=list)
@@ -62,9 +79,18 @@ class AssignmentSelection(BaseModel):
reason: str = "Noch keine Zuordnung vorhanden."
class SensorWeightGroup(BaseModel):
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
name: str = Field(min_length=1, max_length=120)
entity_ids: list[str] = Field(default_factory=list)
weight: float = Field(default=1.0, ge=0.0, le=1.0)
class ManualOverride(BaseModel):
numeric_entity_id: str | None = None
context_entity_ids: list[str] = Field(default_factory=list)
sensor_weights: dict[str, float] = Field(default_factory=dict)
sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
note: str | None = None
@@ -110,6 +136,61 @@ class BehaviorPrediction(BaseModel):
execution_reason: str = "Vorhersage wurde noch nicht ausgeführt."
class DecisionFactor(BaseModel):
entity_id: str | None = None
label: str
factor_type: str = Field(max_length=40)
state: str | None = None
weight: float = Field(default=1.0, ge=0.0, le=1.0)
contribution: float = Field(default=0.0, ge=0.0, le=1.0)
evidence: list[str] = Field(default_factory=list)
class SafetyRule(BaseModel):
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=160)
enabled: bool = True
blocking: bool = True
reason: str = Field(default="", max_length=300)
def default_safety_rules() -> list[SafetyRule]:
return [
SafetyRule(
rule_id="activation_ready",
label="Nur nach Lernfreigabe aktiv schalten",
reason="Der Aktor muss genug eindeutiges Verhalten gelernt haben.",
),
SafetyRule(
rule_id="confidence_threshold",
label="Mindest-Sicherheit einhalten",
reason="Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus.",
),
SafetyRule(
rule_id="cooldown",
label="Sicherheits-Cooldown gegen Hin-und-her-Schalten",
reason="Gleiche Zielzustände werden nicht zu schnell wiederholt.",
),
SafetyRule(
rule_id="manual_block",
label="Manuelle Sperre respektieren",
reason="Nutzer können jeden Aktor sofort blockieren.",
),
]
class SafetyProfile(BaseModel):
stage: SafetyStage = SafetyStage.SHADOW
manual_block: bool = False
min_confidence: float = Field(default=0.82, ge=0.0, le=1.0)
min_confidence_on: float | None = Field(default=None, ge=0.0, le=1.0)
min_confidence_off: float | None = Field(default=None, ge=0.0, le=1.0)
cooldown_seconds: int | None = Field(default=None, ge=0)
rules: list[SafetyRule] = Field(default_factory=default_safety_rules)
updated_at: datetime | None = None
note: str | None = Field(default=None, max_length=500)
class ExecutionEvent(BaseModel):
target_state: str
executed_at: datetime
@@ -139,6 +220,16 @@ class BehaviorState(BaseModel):
related_automations: list[RelatedAutomation] = Field(default_factory=list)
paused_automation_entity_ids: list[str] = Field(default_factory=list)
reason: str = "Historische Aktorhandlungen werden analysiert."
safety: SafetyProfile = Field(default_factory=SafetyProfile)
decision_factors: list[DecisionFactor] = Field(default_factory=list)
knowledge: list[str] = Field(default_factory=list)
assumptions: list[str] = Field(default_factory=list)
uncertainties: list[str] = Field(default_factory=list)
safety_blockers: list[str] = Field(default_factory=list)
sample_trend: list[int] = Field(default_factory=list)
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)
class ActuatorRecord(BaseModel):
@@ -165,5 +256,22 @@ class ReconciliationState(BaseModel):
last_summary: str = "Noch keine Reconciliation ausgeführt."
class JobQueueItem(BaseModel):
job_id: str = Field(min_length=1, max_length=120)
kind: str = Field(min_length=1, max_length=40)
target: str | None = Field(default=None, max_length=160)
trigger: str = Field(default="manual", max_length=40)
status: JobStatus = JobStatus.PENDING
started_at: datetime | None = None
completed_at: datetime | None = None
duration_ms: int | None = Field(default=None, ge=0)
error: str | None = Field(default=None, max_length=500)
summary: str = Field(default="", max_length=500)
class JobQueueState(BaseModel):
jobs: list[JobQueueItem] = Field(default_factory=list)
def model_id_for_actuator(actuator_entity_id: str) -> str:
return f"actuator.{actuator_entity_id}"

View File

@@ -8,6 +8,9 @@ from threading import RLock
from app.actuators.models import (
ActuatorRecord,
JobQueueItem,
JobQueueState,
JobStatus,
LifecycleStatus,
ModelLifecycleState,
ReconciliationState,
@@ -22,6 +25,7 @@ class ActuatorStore:
self._actuators_root.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._reconciliation_state_path = self._root / "reconciliation_state.json"
self._job_queue_path = self._root / "job_queue.json"
def list(self) -> list[ActuatorRecord]:
with self._lock:
@@ -85,6 +89,75 @@ class ActuatorStore:
self._persist_reconciliation_state(state)
return state
def load_job_queue(self) -> JobQueueState:
with self._lock:
if not self._job_queue_path.exists():
return JobQueueState()
try:
return JobQueueState.model_validate_json(
self._job_queue_path.read_text(encoding="utf-8")
)
except ValueError as exc:
raise ValueError("Ungültiger Job-Queue-Status.") from exc
def start_job(
self,
*,
kind: str,
trigger: str,
target: str | None = None,
summary: str = "",
) -> JobQueueItem:
now = datetime.now(timezone.utc)
job = JobQueueItem(
job_id=f"{now.strftime('%Y%m%d%H%M%S%f')}-{kind}-{target or 'all'}",
kind=kind,
target=target,
trigger=trigger,
status=JobStatus.RUNNING,
started_at=now,
summary=summary,
)
with self._lock:
queue = self.load_job_queue()
queue.jobs = [*queue.jobs, job][-50:]
self._persist_job_queue(queue)
return job
def finish_job(
self,
job_id: str,
*,
status: JobStatus,
summary: str = "",
error: str | None = None,
) -> JobQueueItem | None:
now = datetime.now(timezone.utc)
with self._lock:
queue = self.load_job_queue()
updated_job: JobQueueItem | None = None
jobs: list[JobQueueItem] = []
for job in queue.jobs:
if job.job_id != job_id:
jobs.append(job)
continue
duration_ms = None
if job.started_at is not None:
duration_ms = max(0, int((now - job.started_at).total_seconds() * 1000))
updated_job = job.model_copy(
update={
"status": status,
"completed_at": now,
"duration_ms": duration_ms,
"summary": summary or job.summary,
"error": error,
}
)
jobs.append(updated_job)
queue.jobs = jobs[-50:]
self._persist_job_queue(queue)
return updated_job
def _target(self, actuator_entity_id: str) -> Path:
if "." not in actuator_entity_id:
raise ValueError("Ungültige actuator_entity_id.")
@@ -108,6 +181,14 @@ class ActuatorStore:
)
os.replace(temporary, self._reconciliation_state_path)
def _persist_job_queue(self, state: JobQueueState) -> None:
temporary = self._job_queue_path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(state.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, self._job_queue_path)
@staticmethod
def _load(path: Path) -> ActuatorRecord:
try:

View File

@@ -9,7 +9,8 @@ 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
from app.actuators.models import ActuatorRecord, ReconciliationState, SensorWeightGroup
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.config import Settings
@@ -44,11 +45,21 @@ class ManualAssignmentRequest(BaseModel):
note: str | None = Field(default=None, max_length=500)
class WeightOverrideRequest(BaseModel):
sensor_weights: dict[str, float] = Field(default_factory=dict)
sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=500)
class FeedbackRequest(BaseModel):
correct: bool
expected_state: str | None = Field(default=None, max_length=100)
class SafetyProfileRequest(BaseModel):
safety: SafetyProfile
class ActuatorSuggestion(BaseModel):
entity_id: str
domain: str
@@ -106,6 +117,7 @@ class DashboardOverview(BaseModel):
cache: EntityCacheStatus
actuators: list[ActuatorSummary]
discovery_groups: list[DashboardDiscoveryGroup]
jobs: JobQueueState = Field(default_factory=JobQueueState)
@router.get("/discovery", response_model=list[HaEntitySummary])
@@ -118,8 +130,19 @@ def discover_actuators(
if cached_entities:
entities = {entity.entity_id: entity for entity in cached_entities}
else:
fresh_entities = list(ha_reader.read_entities())
_save_cached_entities(request, fresh_entities)
job = _start_job(
request,
kind="discovery",
trigger="manual" if refresh else "cache-miss",
summary="Home-Assistant-Entities werden gelesen und klassifiziert.",
)
try:
fresh_entities = list(ha_reader.read_entities())
_save_cached_entities(request, fresh_entities)
except Exception as exc:
_finish_job(job, request, status=JobStatus.FAILED, summary="Discovery fehlgeschlagen.", error=str(exc))
raise
_finish_job(job, request, status=JobStatus.COMPLETED, summary=f"{len(fresh_entities)} Entities klassifiziert.")
entities = {entity.entity_id: entity for entity in fresh_entities}
discovered = discover_entities(list(entities.values()))
actuator_ids = _deduplicate_actuator_ids(
@@ -272,6 +295,12 @@ def dashboard_overview(request: Request) -> DashboardOverview:
reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request)
store = getattr(request.app.state, "actuator_store", None)
jobs = (
store.load_job_queue()
if isinstance(store, ActuatorStore)
else JobQueueState()
)
return DashboardOverview(
system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -292,6 +321,7 @@ def dashboard_overview(request: Request) -> DashboardOverview:
),
actuators=actuators,
discovery_groups=cached_groups,
jobs=jobs,
)
@@ -366,6 +396,18 @@ def record_feedback(
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord)
def set_safety_profile(
actuator_entity_id: str,
payload: SafetyProfileRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).set_safety_profile(actuator_entity_id, profile=payload.safety)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
def set_activation(
actuator_entity_id: str,
@@ -406,6 +448,27 @@ def set_manual_assignment(
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/weights", response_model=ActuatorRecord)
def set_weight_overrides(
actuator_entity_id: str,
payload: WeightOverrideRequest,
request: Request,
) -> ActuatorRecord:
try:
_validate_weight_payload(payload)
record = _service(request).set_weight_overrides(
actuator_entity_id,
sensor_weights=payload.sensor_weights,
sensor_weight_groups=payload.sensor_weight_groups,
note=payload.note,
)
return record
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}/related-automations/refresh",
response_model=ActuatorRecord,
@@ -414,11 +477,27 @@ def refresh_related_automations(
actuator_entity_id: str,
request: Request,
) -> ActuatorRecord:
job = _start_job(
request,
kind="automation_refresh",
trigger="manual",
target=actuator_entity_id,
summary="Passende HA-Automationen werden gesucht.",
)
try:
return _behavior(request).refresh_related_automations(actuator_entity_id)
record = _behavior(request).refresh_related_automations(actuator_entity_id)
_finish_job(
job,
request,
status=JobStatus.COMPLETED,
summary=f"{len(record.behavior.related_automations)} Automationen gefunden.",
)
return record
except KeyError as exc:
_finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(exc))
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ValueError, HaClientError) as exc:
_finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(exc))
raise HTTPException(status_code=409, detail=str(exc)) from exc
@@ -459,12 +538,85 @@ def run_reconciliation(
request: Request,
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
) -> ReconciliationState:
state = _service(request).reconcile_all(trigger=trigger)
_behavior(request).train_all()
_behavior(request).evaluate_all()
reconciliation_job = _start_job(
request,
kind="reconciliation",
trigger=trigger,
summary="Kontext, Zuordnung und Modelle werden abgeglichen.",
)
training_job: JobQueueItem | None = None
evaluation_job: JobQueueItem | None = None
try:
state = _service(request).reconcile_all(trigger=trigger)
_finish_job(reconciliation_job, request, status=JobStatus.COMPLETED, summary=state.last_summary)
reconciliation_job = None
training_job = _start_job(
request,
kind="training",
trigger=trigger,
summary="Gelernte Aktorhandlungen werden aktualisiert.",
)
_behavior(request).train_all()
_finish_job(training_job, request, status=JobStatus.COMPLETED, summary="Training abgeschlossen.")
training_job = None
evaluation_job = _start_job(
request,
kind="evaluation",
trigger=trigger,
summary="Aktuelle Vorhersagen werden neu berechnet.",
)
_behavior(request).evaluate_all()
_finish_job(evaluation_job, request, status=JobStatus.COMPLETED, summary="Evaluation abgeschlossen.")
evaluation_job = None
except Exception as exc:
for job in [reconciliation_job, training_job, evaluation_job]:
if isinstance(job, JobQueueItem) and job.status is JobStatus.RUNNING:
_finish_job(job, request, status=JobStatus.FAILED, summary="Job fehlgeschlagen.", error=str(exc))
raise
return state
@router.get("/job-queue/state", response_model=JobQueueState)
def get_job_queue(request: Request) -> JobQueueState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Actuator Store nicht initialisiert.",
)
return store.load_job_queue()
def _start_job(
request: Request,
*,
kind: str,
trigger: str,
target: str | None = None,
summary: str = "",
) -> JobQueueItem | None:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
return None
return store.start_job(kind=kind, trigger=trigger, target=target, summary=summary)
def _finish_job(
job: JobQueueItem | None,
request: Request,
*,
status: JobStatus,
summary: str,
error: str | None = None,
) -> None:
if job is None:
return
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
return
store.finish_job(job.job_id, status=status, summary=summary, error=error)
def _service(request: Request) -> ActuatorReconciliationService:
service = getattr(request.app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService):
@@ -485,6 +637,20 @@ def _behavior(request: Request) -> BehaviorEngine:
return engine
def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
for entity_id, weight in payload.sensor_weights.items():
if "." not in entity_id:
raise ValueError(f"Ungültige Entity-ID: {entity_id}")
if not 0.0 <= weight <= 1.0:
raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
for group in payload.sensor_weight_groups:
if not group.entity_ids:
raise ValueError(f"Gruppe {group.name} enthält keine Entities.")
for entity_id in group.entity_ids:
if "." not in entity_id:
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):

View File

@@ -12,8 +12,11 @@ from app.actuators.models import (
BehaviorPrediction,
BehaviorState,
BehaviorStatus,
DecisionFactor,
ExecutionEvent,
RelatedAutomation,
SafetyProfile,
SafetyStage,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
@@ -175,6 +178,10 @@ class BehaviorEngine:
"patterns": patterns[-_MAX_PATTERNS:],
"last_trained_at": now,
"reason": reason,
"sample_trend": [*record.behavior.sample_trend, len(patterns)][-30:],
"knowledge": _knowledge_lines(record, len(patterns), trusted_actions),
"assumptions": _assumption_lines(record),
"uncertainties": _uncertainty_lines(record, len(patterns), trusted_actions),
}
)
return self._save_behavior(record, behavior)
@@ -268,16 +275,25 @@ class BehaviorEngine:
timezone_name=self._settings.timezone,
)
if prediction is not None:
safety_allowed, safety_blockers = self._assess_safety(
record,
actuator.state,
prediction,
now,
)
prediction = prediction.model_copy(
update={
"execution_reason": self._prediction_execution_reason(
record,
actuator.state,
prediction,
now,
"execution_reason": (
"Ausführung ist freigegeben."
if safety_allowed
else "Nicht ausgeführt: " + " ".join(safety_blockers)
)
}
)
else:
safety_allowed = False
safety_blockers = ["Keine fällige Vorhersage."]
decision_factors = _decision_factors_for(record, current_context, prediction)
behavior = record.behavior.model_copy(
update={
"last_evaluated_at": now,
@@ -287,18 +303,21 @@ class BehaviorEngine:
if prediction is not None
else "Aktuell ist kein gelerntes Handlungsmuster fällig."
),
"decision_factors": decision_factors,
"knowledge": _knowledge_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count),
"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 [],
"confidence_trend": (
[*record.behavior.confidence_trend, round(prediction.confidence, 4)][-30:]
if prediction is not None
else record.behavior.confidence_trend
),
}
)
if (
prediction is not None
and behavior.mode is BehaviorMode.ACTIVE
and prediction.confidence >= self._settings.prediction_confidence
and actuator.state != prediction.target_state
and self._cooldown_elapsed(
behavior,
now,
prediction.target_state,
)
and safety_allowed
):
domain = actuator_entity_id.split(".", 1)[0]
service = service_for_state(domain, prediction.target_state)
@@ -403,6 +422,8 @@ class BehaviorEngine:
)
)
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
correct_count = record.behavior.correct_feedback_count + 1
incorrect_count = record.behavior.incorrect_feedback_count
else:
target = prediction.target_state if prediction is not None else None
if target:
@@ -431,6 +452,8 @@ 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
behavior = record.behavior.model_copy(
update={
"patterns": patterns[-_MAX_PATTERNS:],
@@ -441,6 +464,23 @@ class BehaviorEngine:
),
"reason": reason,
"last_trained_at": now,
"correct_feedback_count": correct_count,
"incorrect_feedback_count": incorrect_count,
}
)
return self._save_behavior(record, behavior)
def set_safety_profile(
self,
actuator_entity_id: str,
*,
profile: SafetyProfile,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
behavior = record.behavior.model_copy(
update={
"safety": profile.model_copy(update={"updated_at": datetime.now(timezone.utc)}),
"reason": "Sicherheitsprofil wurde manuell aktualisiert.",
}
)
return self._save_behavior(record, behavior)
@@ -527,6 +567,9 @@ class BehaviorEngine:
update={
"mode": mode,
"approved_at": approved_at,
"safety": record.behavior.safety.model_copy(
update={"stage": SafetyStage.ACTIVE, "updated_at": now}
),
"reason": (
"Autonomes Lernen und Schalten wurde ausdrücklich freigegeben."
),
@@ -607,6 +650,9 @@ class BehaviorEngine:
update={
"mode": mode,
"approved_at": approved_at,
"safety": record.behavior.safety.model_copy(
update={"stage": SafetyStage.SHADOW, "updated_at": now}
),
"related_automations": [
automation.model_copy(update={"enabled": True})
if (
@@ -651,6 +697,51 @@ class BehaviorEngine:
return "Nicht ausgeführt: Sicherheits-Cooldown ist noch aktiv."
return "Ausführung ist freigegeben."
def _assess_safety(
self,
record: ActuatorRecord,
current_state: str | None,
prediction: BehaviorPrediction,
now: datetime,
) -> tuple[bool, list[str]]:
profile = record.behavior.safety
blockers: list[str] = []
domain = record.actuator_entity_id.split(".", 1)[0]
if not record.enabled:
blockers.append("Aktor ist in SillyHome deaktiviert.")
if domain not in _SAFE_ACTIVE_DOMAINS:
blockers.append(f"Domain {domain} ist nicht für autonomes Schalten freigegeben.")
if profile.manual_block:
blockers.append("Manuelle Sicherheitssperre ist aktiv.")
stage = profile.stage
if (
record.behavior.mode is BehaviorMode.ACTIVE
and profile.updated_at is None
and stage is SafetyStage.SHADOW
):
stage = SafetyStage.ACTIVE
if stage not in {SafetyStage.ACTIVE, SafetyStage.PARTIAL}:
blockers.append(f"Safety-Stufe {stage.value} erlaubt noch kein Schalten.")
if record.behavior.mode is not BehaviorMode.ACTIVE:
blockers.append("SillyHome ist im Shadow-Modus.")
if not record.behavior.activation_ready:
blockers.append(record.behavior.activation_reason)
threshold = _confidence_threshold_for(profile, prediction.target_state)
if prediction.confidence < threshold:
blockers.append(
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
)
if current_state == prediction.target_state:
blockers.append("Zielzustand ist bereits erreicht.")
if not self._cooldown_elapsed(
record.behavior,
now,
prediction.target_state,
cooldown_seconds=profile.cooldown_seconds,
):
blockers.append("Sicherheits-Cooldown ist noch aktiv.")
return not blockers, blockers
def _build_patterns(
self,
*,
@@ -701,6 +792,8 @@ class BehaviorEngine:
behavior: BehaviorState,
now: datetime,
target_state: str,
*,
cooldown_seconds: int | None = None,
) -> bool:
if behavior.last_executed_at is None:
return True
@@ -708,7 +801,9 @@ class BehaviorEngine:
if last_event is not None and last_event.target_state != target_state:
return True
return (now - behavior.last_executed_at) >= timedelta(
seconds=self._settings.execution_cooldown_seconds
seconds=cooldown_seconds
if cooldown_seconds is not None
else self._settings.execution_cooldown_seconds
)
def _save_behavior(
@@ -791,6 +886,110 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
return parsed
def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float:
if target_state == "on" and profile.min_confidence_on is not None:
return profile.min_confidence_on
if target_state in {"off", "closed"} and profile.min_confidence_off is not None:
return profile.min_confidence_off
return profile.min_confidence
def _decision_factors_for(
record: ActuatorRecord,
current_context: dict[str, str | None],
prediction: BehaviorPrediction | None,
) -> list[DecisionFactor]:
factors: list[DecisionFactor] = []
candidates = {
candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
for entity_id, state in current_context.items():
candidate = candidates.get(entity_id)
weight = candidate.effective_weight if candidate is not None else 1.0
relevance = candidate.confidence if candidate is not None else 0.5
contribution = round(min(1.0, weight * relevance), 4)
factors.append(
DecisionFactor(
entity_id=entity_id,
label=(
candidate.friendly_name
if candidate is not None and candidate.friendly_name
else entity_id
),
factor_type="context",
state=state,
weight=round(weight, 4),
contribution=contribution,
evidence=(
candidate.evidence[:4]
if candidate is not None
else ["Aktuell ausgewähltes Kontextsignal."]
),
)
)
if prediction is not None:
factors.append(
DecisionFactor(
label=f"Vorhersage {prediction.target_state}",
factor_type="prediction",
state=prediction.target_state,
weight=1.0,
contribution=prediction.confidence,
evidence=[prediction.reason],
)
)
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
def _knowledge_lines(
record: ActuatorRecord,
sample_count: int,
trusted_actions: int,
) -> list[str]:
lines = [
f"{sample_count} historische Aktorhandlungen sind ausgewertet.",
f"{trusted_actions} Handlungen stammen eindeutig von Nutzer oder HA-Automationen.",
]
if record.assignment.selected_numeric_entity_id:
lines.append(f"Hauptsensor: {record.assignment.selected_numeric_entity_id}.")
if record.assignment.selected_context_entity_ids:
lines.append(
f"{len(record.assignment.selected_context_entity_ids)} Kontextsignale sind verbunden."
)
return lines
def _assumption_lines(record: ActuatorRecord) -> list[str]:
lines = [
"Ähnliche Zeitfenster und ähnliche Kontextzustände deuten auf ähnliche Nutzerabsicht hin."
]
if record.manual_override is not None:
lines.append("Manuelle Sensor-/Kontextkorrekturen werden höher gewichtet.")
if record.behavior.related_automations:
lines.append("Passende HA-Automationen gelten als starker Hinweis auf vorhandene Logik.")
return lines
def _uncertainty_lines(
record: ActuatorRecord,
sample_count: int,
trusted_actions: int,
) -> list[str]:
lines: list[str] = []
if sample_count < trusted_actions + 3:
lines.append("Noch wenig Varianz in den gelernten Handlungen.")
if trusted_actions < sample_count:
lines.append("Ein Teil der Handlungen ist nicht eindeutig Nutzer oder Automation zugeordnet.")
if record.assignment.review_required:
lines.append("Die automatische Kontextzuordnung verlangt noch Prüfung.")
if record.behavior.incorrect_feedback_count:
lines.append(
f"{record.behavior.incorrect_feedback_count} negative Feedbacks senken Vertrauen."
)
return lines or ["Keine kritische Unsicherheit aus den lokalen Daten erkannt."]
def predict_behavior(
patterns: list[BehaviorPattern],
*,

View File

@@ -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.0.3",
version="1.1.0",
lifespan=lifespan,
)
app.state.settings = load_settings()

View File

@@ -25,6 +25,7 @@
--bad:#8fb8ff;
}
* { box-sizing:border-box; }
html, body { max-width:100%; overflow-x:hidden; }
body { margin:0; font-size:15px; background:var(--panel-quiet); }
h1,h2,h3 { margin:0 0 10px; letter-spacing:0; }
p { margin:6px 0; }
@@ -71,31 +72,38 @@
.bad { color: var(--bad); }
label { display:block; margin:9px 0 4px; color:#c3d2df; font-weight:700; }
select,input,button { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:10px; background:#111821; color:#fff; font:inherit; min-width:0; }
input[type="checkbox"] { width:auto; min-width:0; vertical-align:middle; margin-right:8px; }
select[multiple] { min-height:150px; }
button { min-height:42px; margin-top:10px; background:var(--accent); color:#211204; border:0; font-weight:850; cursor:pointer; }
button.secondary { background:var(--complement-soft); color:#dff6ff; border:1px solid #22607c; }
button.danger { background:#2a3441; color:#f2f6fb; border:1px solid #536273; }
button.compact { width:auto; min-width:112px; margin-right:8px; padding:8px 10px; min-height:36px; }
table { width: 100%; border-collapse: collapse; font-size: .92rem; }
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
table { width: 100%; border-collapse: collapse; table-layout:fixed; font-size: .92rem; }
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; overflow-wrap:anywhere; word-break:break-word; }
ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left:4px solid var(--complement); padding-left:10px; }
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.chip { padding:4px 8px; border-radius:8px; background:#222b36; border:1px solid var(--border); font-size:.85rem; }
.chip { padding:4px 8px; border-radius:8px; background:#222b36; border:1px solid var(--border); font-size:.85rem; max-width:100%; overflow-wrap:anywhere; word-break:break-word; }
.muted { color:var(--text-soft); }
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:10px; }
.actuator-card { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
.actuator-card.selected { border-color:var(--complement); box-shadow:0 0 0 1px rgba(28,199,255,.35); }
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
.entity-id { overflow-wrap:anywhere; font-weight:800; }
.card-title { display:flex; flex-wrap:wrap; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; min-width:0; }
.card-title > div { min-width:0; flex:1 1 160px; overflow-wrap:anywhere; word-break:break-word; }
.card-title .chip { flex:0 1 auto; white-space:normal; text-align:center; }
.actuator-card strong { overflow-wrap:anywhere; word-break:break-word; }
.entity-id { overflow-wrap:anywhere; word-break:break-word; font-weight:800; }
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
.metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; }
.metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
.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 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; }
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
.manual-context { margin-top:12px; background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; }
.manual-context { margin-top:12px; background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
.manual-entry { min-height:80px; resize:vertical; }
textarea { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
@@ -228,6 +236,7 @@
<div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div>
<div id="dashboard-stats" class="metric-grid"></div>
<div id="job-queue" class="decision-list"></div>
</section>
<section class="guide-panel" id="guide">
@@ -271,6 +280,7 @@ let cachedActuators = null;
let cachedEntities = null;
let cachedDiscovery = null;
let discoveryLoadPromise = null;
let currentSensorWeightGroups = [];
const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 4500;
@@ -481,6 +491,7 @@ function renderDashboardStatus(dashboard) {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
const stats = document.getElementById("dashboard-stats");
const jobsBox = document.getElementById("job-queue");
const system = dashboard.system || {};
const cache = dashboard.cache || {};
const actuators = dashboard.actuators || [];
@@ -493,6 +504,8 @@ 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 jobs = dashboard.jobs?.jobs || [];
const runningJobs = jobs.filter(job => job.status === "running").length;
const cacheLabel = cache.available
? `Cache aktuell mit ${cache.entity_count} Entities`
: "Cache wird nach Discovery aufgebaut";
@@ -508,6 +521,7 @@ function renderDashboardStatus(dashboard) {
`<span class="chip">Aktoren: ${escapeHtml(system.configured_actuators ?? 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">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`,
].join("");
stats.innerHTML = [
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`,
@@ -518,6 +532,21 @@ function renderDashboardStatus(dashboard) {
`<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>`,
].join("");
jobsBox.innerHTML = jobs.length ? `
<h3>Job-Queue</h3>
${jobs.slice(-6).reverse().map(job => `
<div class="decision-row">
<header>
<strong>${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong>
<span class="chip">${escapeHtml(job.status)}</span>
</header>
<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>
${job.error ? `<p class="bad">${escapeHtml(job.error)}</p>` : ""}
${job.status === "failed" ? "<p class='warn'>Retry: Aktion im Dashboard erneut starten; der nächste Lauf schreibt einen neuen Queue-Eintrag.</p>" : ""}
</div>
`).join("")}
` : "";
}
async function loadSummaryData() {
@@ -803,6 +832,62 @@ async function showActuator(actuatorId, evaluationMessage = "") {
.filter(candidate => contexts.includes(candidate.entity_id))
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join("");
const weightedCandidates = [...record.numeric_candidates, ...record.context_candidates]
.filter(candidate => contexts.includes(candidate.entity_id));
const weightGroups = record.manual_override?.sensor_weight_groups || [];
currentSensorWeightGroups = weightGroups;
const weightControls = weightedCandidates.length ? `
<div class="card-list">
${weightedCandidates.map(candidate => {
const relevance = Math.round((candidate.confidence ?? 0) * 100);
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
const manual = candidate.manual_weight == null ? effective : Math.round(candidate.manual_weight * 100);
return `
<article class="actuator-card">
<div class="card-title">
<div>
<div><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong></div>
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
</div>
<span class="chip">${relevance} % relevant</span>
</div>
<div class="metric-grid">
<div class="metric"><strong>Automatische Relevanz</strong>${relevance} %</div>
<div class="metric"><strong>Aktive Gewichtung</strong>${effective} %</div>
<div class="metric"><strong>Score</strong>${escapeHtml(candidate.score)}</div>
</div>
<label for="weight-${escapeHtml(candidate.entity_id)}">Gewichtung korrigieren</label>
<input id="weight-${escapeHtml(candidate.entity_id)}" data-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${manual}">
</article>
`;
}).join("")}
</div>
<div class="actions">
<button onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}')">Gewichtungen speichern</button>
</div>
` : "<p class='muted'>Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.</p>";
const weightGroupControls = `
<details class="manual-context">
<summary>Gruppen-Gewichtung</summary>
${weightGroups.length ? `<ul>${weightGroups.map(group => `
<li><strong>${escapeHtml(group.name)}</strong>: ${Math.round(group.weight * 100)} %
<span class="muted">${group.entity_ids.map(escapeHtml).join(", ")}</span></li>
`).join("")}</ul>` : "<p class='muted'>Noch keine Gruppe gespeichert.</p>"}
<div class="inline-controls">
<div>
<label for="weight-group-name">Gruppenname</label>
<input id="weight-group-name" placeholder="z. B. Flur Bewegung + Helligkeit">
</div>
<div>
<label for="weight-group-value">Gruppen-Gewicht in %</label>
<input id="weight-group-value" type="number" min="0" max="100" step="5" value="100">
</div>
</div>
<label for="weight-group-entities">Entity-IDs der Gruppe</label>
<textarea id="weight-group-entities" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs">${escapeHtml(contexts.join("\n"))}</textarea>
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
</details>
`;
const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => `
<li>
@@ -812,6 +897,71 @@ async function showActuator(actuatorId, evaluationMessage = "") {
`).join("")}</ul>`
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
const prediction = record.behavior.prediction;
const safety = record.behavior.safety || {};
const blockers = record.behavior.safety_blockers || [];
const decisionFactors = record.behavior.decision_factors || [];
const knowledge = record.behavior.knowledge || [];
const assumptions = record.behavior.assumptions || [];
const uncertainties = record.behavior.uncertainties || [];
const safetyControls = `
<details class="manual-context" open>
<summary>Sicherheit und manuelles Gegensteuern</summary>
<div class="inline-controls">
<div>
<label for="safety-stage">Freigabestufe</label>
<select id="safety-stage">
${["observe", "suggest", "shadow", "partial", "active"].map(stage => `
<option value="${stage}" ${safety.stage === stage ? "selected" : ""}>${stage}</option>
`).join("")}
</select>
</div>
<div>
<label for="safety-confidence">Mindest-Sicherheit in %</label>
<input id="safety-confidence" type="number" min="0" max="100" step="1" value="${Math.round((safety.min_confidence ?? 0.82) * 100)}">
</div>
<div>
<label for="safety-cooldown">Cooldown Sekunden</label>
<input id="safety-cooldown" type="number" min="0" step="10" value="${safety.cooldown_seconds ?? ""}" placeholder="Standard">
</div>
</div>
<label>
<input id="safety-manual-block" type="checkbox" ${safety.manual_block ? "checked" : ""}>
Manuelle Sicherheitssperre aktiv
</label>
${blockers.length ? `<p class="warn">Aktuelle Blocker: ${blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.</p>"}
<button class="secondary" onclick="saveSafetyProfile('${escapeHtml(record.actuator_entity_id)}')">Sicherheitsprofil speichern</button>
</details>
`;
const decisionArchive = `
<details class="manual-context" open>
<summary>Entscheidungsakte</summary>
<div class="grid-two">
<div>
<h3>Wissen</h3>
<ul>${knowledge.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine gesicherten Punkte gespeichert.</li>"}</ul>
</div>
<div>
<h3>Annahmen</h3>
<ul>${assumptions.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Annahmen gespeichert.</li>"}</ul>
</div>
</div>
<h3>Unsicherheit</h3>
<ul>${uncertainties.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Unsicherheit gespeichert.</li>"}</ul>
<h3>Beitragsfaktoren</h3>
<div class="decision-list">
${decisionFactors.length ? decisionFactors.map(factor => `
<div class="decision-row">
<header>
<strong>${escapeHtml(factor.label)}</strong>
<span class="chip">${Math.round((factor.contribution || 0) * 100)} % Beitrag</span>
</header>
<p class="muted">${escapeHtml(factor.entity_id || factor.factor_type)} · Zustand: ${escapeHtml(factor.state || "offen")} · Gewicht: ${Math.round((factor.weight || 0) * 100)} %</p>
<p>${(factor.evidence || []).map(escapeHtml).join(" ")}</p>
</div>
`).join("") : "<p class='muted'>Noch keine aktuelle Entscheidungsfaktoren berechnet.</p>"}
</div>
</details>
`;
const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation",
).length;
@@ -921,12 +1071,18 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', true)">Vorhersage korrekt</button>
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button>
</div>
${safetyControls}
${decisionArchive}
<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>
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
<h3>Sensor-Gewichtung</h3>
<p class="muted">Automatische Relevanz kommt aus der Zuordnung. Die aktive Gewichtung kannst du korrigieren; Gruppen bündeln mehrere Sensoren/Zustände.</p>
${weightControls}
${weightGroupControls}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment}
@@ -986,6 +1142,45 @@ async function hydrateCurrentContextOptions(actuatorId) {
await hydrateContextOptions(record);
}
async function saveWeightOverrides(actuatorId, includeNewGroup = false) {
const sensorWeights = {};
for (const input of document.querySelectorAll("[data-weight-entity]")) {
const value = Number(input.value);
if (Number.isFinite(value)) {
sensorWeights[input.dataset.weightEntity] = Math.max(0, Math.min(100, value)) / 100;
}
}
const groups = [...currentSensorWeightGroups];
if (includeNewGroup) {
const name = document.getElementById("weight-group-name")?.value.trim();
const value = Number(document.getElementById("weight-group-value")?.value || 100);
const entityIds = parseEntityIds(document.getElementById("weight-group-entities")?.value || "");
if (name && entityIds.length) {
groups.push({
group_id: name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "gruppe",
name,
entity_ids: entityIds,
weight: Math.max(0, Math.min(100, Number.isFinite(value) ? value : 100)) / 100,
});
}
}
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/weights`, {
method: "POST",
body: JSON.stringify({
sensor_weights: sensorWeights,
sensor_weight_groups: groups,
note: "Gewichtung im Dashboard korrigiert",
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Sensor-Gewichtung gespeichert.");
} catch (error) {
alert(error.message);
}
}
async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from(
@@ -1080,6 +1275,36 @@ async function sendFeedback(actuatorId, correct) {
}
}
async function saveSafetyProfile(actuatorId) {
const confidence = Number(document.getElementById("safety-confidence")?.value || 82);
const cooldownRaw = document.getElementById("safety-cooldown")?.value || "";
const cooldown = cooldownRaw === "" ? null : Math.max(0, Number(cooldownRaw));
const profile = {
stage: document.getElementById("safety-stage")?.value || "shadow",
manual_block: Boolean(document.getElementById("safety-manual-block")?.checked),
min_confidence: Math.max(0, Math.min(100, Number.isFinite(confidence) ? confidence : 82)) / 100,
cooldown_seconds: Number.isFinite(cooldown) ? cooldown : null,
rules: [
{rule_id: "activation_ready", label: "Nur nach Lernfreigabe aktiv schalten", enabled: true, blocking: true, reason: "Der Aktor muss genug eindeutiges Verhalten gelernt haben."},
{rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus."},
{rule_id: "cooldown", label: "Sicherheits-Cooldown gegen Hin-und-her-Schalten", enabled: true, blocking: true, reason: "Gleiche Zielzustände werden nicht zu schnell wiederholt."},
{rule_id: "manual_block", label: "Manuelle Sperre respektieren", enabled: true, blocking: true, reason: "Nutzer können jeden Aktor sofort blockieren."},
],
note: "Sicherheitsprofil im Dashboard gespeichert",
};
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/safety`, {
method: "POST",
body: JSON.stringify({safety: profile}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Sicherheitsprofil gespeichert.");
} catch (error) {
alert(error.message);
}
}
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
const question = active
? pauseMatchingAutomations

View File

@@ -101,6 +101,21 @@ wget -qO /tmp/summary.json http://58adbe1e-sillyhome-next:8000/v1/actuators/summ
wget -qO /tmp/dashboard.json http://58adbe1e-sillyhome-next:8000/v1/actuators/dashboard
```
Wenn der Add-on-Container aus dem Agent-Host nicht direkt routbar ist, gilt der
Home-Assistant-Supervisor als Verifikationsquelle:
- Add-on-Info pruefen: Version, `version_latest`, `update_available`, `state`,
`boot` und `watchdog`.
- Vor Updates eine Home-Assistant-Teil-Sicherung fuer **SillyHome Next**
erstellen.
- Nach einem Store-Reload und Update muss `version == version_latest`,
`update_available == false`, `state == started`, `boot == auto` und
`watchdog == true` gelten.
- Den HA-/Ingress-Tab nach jedem Update hart neu laden, weil Home Assistant
sonst alte HTML-/JavaScript-Ressourcen aus dem bestehenden Tab verwenden kann.
- Rollback erfolgt ueber die vorherige Add-on-Teil-Sicherung oder den letzten
Git-Tag; beide Referenzen im Release-/Abnahmeprotokoll notieren.
## Rollback
Der stabile Vor-1.0-Stand ist `v0.7.21`. Vor dem 1.0.0-Umbau wurde ein

View File

@@ -44,6 +44,12 @@ expliziter Freigabe.
- `ruff check .`
- `mypy app backend tests`
- `git diff --check`
- Performance-Budget:
- Automatisierter Test prueft Root-HTML und `/v1/actuators/dashboard` gegen
das 5-Sekunden-Budget mit kontrollierten Fake-HA-/Cache-Daten.
- HA-/Ingress-Verifikation:
- Supervisor-Update, Add-on-Status, Watchdog, Backup, Ingress-Hard-Reload
und Rollback sind im Operating Guide dokumentiert.
## Teilweise Erfuellt
@@ -62,14 +68,10 @@ expliziter Freigabe.
## Offen Fuer v1.0.x
- Echte Dashboard-Performance-Budget-Tests, die Start-HTML und
`/v1/actuators/dashboard` gegen ein 5-Sekunden-Limit messen.
- Dashboard-Jobstatus fuer Reconciliation, Training, Discovery und
Automation-Refresh.
- Mehr Entscheidungsstatistik pro Aktor: welche Sensoren wie stark
beigetragen haben, wie sich Confidence und Sample Count entwickeln.
- Dokumentierte HA-Installationspruefung mit Supervisor-/Ingress-Hinweisen,
weil direkte Container-HTTP-Pruefung ausserhalb HA nicht immer routbar ist.
## Rollback

View File

@@ -0,0 +1,72 @@
# SillyHome Next v1.1.0 Operating Guide
## Ziel
v1.1.0 macht das Dashboard zur Zentrale fuer Visualisierung, Einrichtung,
Sicherheit und manuelles Gegensteuern. Autonomes Schalten bleibt ein kurzer
lokaler Pfad: Vorhersage und Safety-Profil werden aus bereits vorhandenen Daten
bewertet, danach folgt direkt der Home-Assistant-Serviceaufruf.
## Sicherheitsmodell
Jeder Aktor hat ein Safety-Profil:
- `stage`: Beobachten, Vorschlagen, Shadow, Teilaktiv oder Aktiv.
- `manual_block`: harte manuelle Sperre.
- `min_confidence`: Mindest-Sicherheit fuer autonomes Schalten.
- `cooldown_seconds`: optionaler Aktor-Cooldown gegen schnelles Hin-und-her.
- Safety-Regeln: Freigabe, Confidence, Cooldown und manuelle Sperre.
Ein Aktor schaltet nur, wenn alle lokalen Safety-Regeln frei sind, der
Behavior-Modus aktiv ist, die Freigabe bereit ist, die Confidence passt, der
Zielzustand noch nicht erreicht ist und der Cooldown abgelaufen ist.
## Transparenz
Die Aktor-Detailansicht trennt:
- Wissen: belegte Fakten aus Historie, Zuordnung und Automationen.
- Annahmen: heuristische Schluesse wie Zeit-/Kontext-Aehnlichkeit.
- Unsicherheiten: geringe Datenmenge, unklare Quellen, Review-Bedarf oder
negatives Feedback.
- Beitragsfaktoren: Sensoren, Kontextsignale, aktive Gewichtung und Beitrag.
- Safety-Blocker: Gruende, warum nicht geschaltet wird.
## Job-Queue
Das Dashboard zeigt die letzten Jobs mit Status, Dauer, Fehler und
Zusammenfassung. Sichtbar sind:
- Discovery
- Reconciliation
- Training
- Evaluation
- Automation-Refresh
Die Queue ist persistent in `job_queue.json` und dient als Betriebsanzeige. Sie
blockiert nicht den Startpfad und nicht den Schaltpfad.
## Manuelles Gegensteuern
Im Dashboard koennen pro Aktor gesetzt werden:
- manuelle Sicherheitssperre
- Freigabestufe
- Mindest-Confidence
- optionaler Cooldown
- Sensor-Gewichtungen und Gruppen-Gewichtungen
- Kontextauswahl
- Feedback: Vorhersage korrekt/falsch
- HA-Automationen pausieren/fortsetzen
## Qualitaetspruefung
Vor Release:
```bash
.venv/bin/pytest -q
.venv/bin/ruff check .
.venv/bin/mypy app backend tests
git diff --check
node --check /tmp/sillyhome-dashboard.js
```

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
version = "1.0.3"
version = "1.1.0"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11"
dependencies = [

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from time import perf_counter
from datetime import datetime, timedelta
from pathlib import Path
@@ -29,6 +30,7 @@ class FakeHaReader(HaReader):
self._entities = entities
self._history = history
self.read_entities_calls = 0
self.service_calls: list[tuple[str, str, dict[str, object]]] = []
def read_entities(self) -> list[HaEntitySummary]:
self.read_entities_calls += 1
@@ -85,6 +87,7 @@ class FakeHaReader(HaReader):
service: str,
service_data: dict[str, object],
) -> list[object]:
self.service_calls.append((domain, service, service_data))
return []
def find_automations_for_entity(
@@ -215,6 +218,88 @@ def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
]
def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
client.post(
"/v1/actuators/light.abstellkammer/assignment",
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
},
)
response = client.post(
"/v1/actuators/light.abstellkammer/weights",
json={
"sensor_weights": {
"sensor.abstellkammer_illuminance": 0.75,
"binary_sensor.abstellkammer_motion": 0.5,
},
"sensor_weight_groups": [
{
"group_id": "abstellkammer_context",
"name": "Abstellkammer Kontext",
"entity_ids": [
"sensor.abstellkammer_illuminance",
"binary_sensor.abstellkammer_motion",
],
"weight": 0.8,
}
],
"note": "Gewichtung korrigiert",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["manual_override"]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.75
assert payload["manual_override"]["sensor_weight_groups"][0]["group_id"] == (
"abstellkammer_context"
)
numeric = {
candidate["entity_id"]: candidate
for candidate in payload["numeric_candidates"]
}
assert numeric["sensor.abstellkammer_illuminance"]["manual_weight"] == 0.75
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.post(
"/v1/actuators/light.abstellkammer/safety",
json={
"safety": {
"stage": "shadow",
"manual_block": True,
"min_confidence": 0.9,
"cooldown_seconds": 120,
"rules": [
{
"rule_id": "manual_block",
"label": "Manuelle Sperre respektieren",
"enabled": True,
"blocking": True,
"reason": "Test",
}
],
"note": "Test",
}
},
)
assert response.status_code == 200
payload = response.json()
assert payload["behavior"]["safety"]["manual_block"] is True
assert payload["behavior"]["safety"]["min_confidence"] == 0.9
assert payload["behavior"]["safety"]["cooldown_seconds"] == 120
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
@@ -249,6 +334,46 @@ def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> N
assert payload["cache"]["entity_count"] == 4
assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
assert payload["discovery_groups"]
assert payload["jobs"]["jobs"][-1]["kind"] == "discovery"
def test_reconciliation_run_records_visible_job_queue(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.post("/v1/actuators/reconciliation/run")
jobs = client.get("/v1/actuators/job-queue/state")
assert response.status_code == 200
assert jobs.status_code == 200
payload = jobs.json()
assert [job["kind"] for job in payload["jobs"][-3:]] == [
"reconciliation",
"training",
"evaluation",
]
assert payload["jobs"][-1]["status"] == "completed"
def test_dashboard_start_path_stays_within_five_second_budget(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"})
root_started_at = perf_counter()
root_response = client.get("/")
root_elapsed = perf_counter() - root_started_at
dashboard_started_at = perf_counter()
dashboard_response = client.get("/v1/actuators/dashboard")
dashboard_elapsed = perf_counter() - dashboard_started_at
assert root_response.status_code == 200
assert dashboard_response.status_code == 200
assert root_elapsed < 5.0
assert dashboard_elapsed < 5.0
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None: