Compare commits

..

5 Commits

Author SHA1 Message Date
2ec2c64cba Add adaptive learning and model rollback
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:41:03 +02:00
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
94530d3ecf Stream dashboard loading and header menu
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:55:28 +02:00
16 changed files with 1738 additions and 70 deletions

View File

@@ -1,5 +1,58 @@
# Changelog # Changelog
## 1.2.0 - 2026-06-17
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
wertet sie vorsichtig ab.
- Modell-Snapshots mit aktivem Modellstand und Rollback-API ergaenzt.
- Dashboard zeigt Modell-Snapshots, Rollback, Zeitprofile,
adaptive Gewichtungsupdates und Automation-Konflikte.
- Automation-Refresh markiert Konflikte, wenn SillyHome aktiv ist und passende
HA-Automationen parallel aktiv bleiben.
- Zeitprofile fuer Nacht, Morgen, Tag, Abend und Wochenende werden aus
gelernten Handlungen gebildet.
## 1.1.0 - 2026-06-17
- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert:
Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/
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.
- Dashboard startet in Phasen: leere Bedienoberflaeche, dann Status, danach
Geraetedaten.
- Detailansicht oeffnet streamartiger: zuerst Basis-Shell, dann Aktorwerte,
danach Kontextvorschlaege.
## 1.0.2 - 2026-06-17 ## 1.0.2 - 2026-06-17
- v1.0-Abnahme als `docs/V1_0_ACCEPTANCE.md` dokumentiert: erledigte, - v1.0-Abnahme als `docs/V1_0_ACCEPTANCE.md` dokumentiert: erledigte,
teilweise erledigte und offene v1.0.x-Punkte sind getrennt sichtbar. teilweise erledigte und offene v1.0.x-Punkte sind getrennt sichtbar.

View File

@@ -15,6 +15,10 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_0_0_OPERATING_GUIDE.md`](docs/V1_0_0_OPERATING_GUIDE.md) [`docs/V1_0_0_OPERATING_GUIDE.md`](docs/V1_0_0_OPERATING_GUIDE.md)
- Version 1.0.x Abnahme und offene Punkte: - Version 1.0.x Abnahme und offene Punkte:
[`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md) [`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md)
- Version 1.1.0 Safety, Transparenz und Job-Queue:
[`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md)
- Version 1.2.0 adaptive Gewichtung, Rollback und Profile:
[`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) - 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.0.2" version: "1.2.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

@@ -16,6 +16,7 @@ from app.actuators.models import (
ManualOverride, ManualOverride,
ModelLifecycleState, ModelLifecycleState,
ReconciliationState, ReconciliationState,
SensorWeightGroup,
model_id_for_actuator, model_id_for_actuator,
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
@@ -239,6 +240,10 @@ class ActuatorReconciliationService:
override = ManualOverride( override = ManualOverride(
numeric_entity_id=numeric_entity_id, numeric_entity_id=numeric_entity_id,
context_entity_ids=selected_context_ids, 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, updated_at=now,
note=note, note=note,
) )
@@ -253,17 +258,23 @@ class ActuatorReconciliationService:
update={ update={
"assignment": assignment, "assignment": assignment,
"manual_override": override, "manual_override": override,
"numeric_candidates": _merge_manual_candidates( "numeric_candidates": _apply_weight_overrides(
record.numeric_candidates, _merge_manual_candidates(
entities, record.numeric_candidates,
[numeric_entity_id] if numeric_entity_id else [], entities,
role=EntityRole.MEASUREMENT, [numeric_entity_id] if numeric_entity_id else [],
role=EntityRole.MEASUREMENT,
),
override,
), ),
"context_candidates": _merge_manual_candidates( "context_candidates": _apply_weight_overrides(
record.context_candidates, _merge_manual_candidates(
entities, record.context_candidates,
selected_context_ids, entities,
role=EntityRole.CONTEXT, selected_context_ids,
role=EntityRole.CONTEXT,
),
override,
), ),
"lifecycle": lifecycle, "lifecycle": lifecycle,
"updated_at": now, "updated_at": now,
@@ -271,6 +282,65 @@ class ActuatorReconciliationService:
) )
return self._store.upsert(updated) 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: def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
state = self._store.load_reconciliation_state().model_copy( state = self._store.load_reconciliation_state().model_copy(
update={ update={
@@ -376,6 +446,9 @@ class ActuatorReconciliationService:
), ),
context=True, 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 = ( assignment = (
self._manual_assignment(record.manual_override) self._manual_assignment(record.manual_override)
if record.manual_override is not None 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)) 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]: def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context: if context:
mapping = { mapping = {

View File

@@ -37,6 +37,21 @@ class BehaviorStatus(StrEnum):
BLOCKED = "blocked" 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): class AssignmentCandidate(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
@@ -49,6 +64,8 @@ class AssignmentCandidate(BaseModel):
device_name: str | None = None device_name: str | None = None
score: float = Field(ge=0.0) score: float = Field(ge=0.0)
confidence: float = Field(ge=0.0, le=1.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 auto_accepted: bool = False
evidence: list[str] = Field(default_factory=list) evidence: list[str] = Field(default_factory=list)
@@ -62,9 +79,18 @@ class AssignmentSelection(BaseModel):
reason: str = "Noch keine Zuordnung vorhanden." 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): class ManualOverride(BaseModel):
numeric_entity_id: str | None = None numeric_entity_id: str | None = None
context_entity_ids: list[str] = Field(default_factory=list) 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)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
note: str | None = None note: str | None = None
@@ -110,11 +136,101 @@ class BehaviorPrediction(BaseModel):
execution_reason: str = "Vorhersage wurde noch nicht ausgeführt." 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 AdaptiveWeightUpdate(BaseModel):
entity_id: str
previous_weight: float = Field(ge=0.0, le=1.0)
new_weight: float = Field(ge=0.0, le=1.0)
reason: str = Field(max_length=300)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class SafetyRule(BaseModel):
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=160)
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): class ExecutionEvent(BaseModel):
target_state: str target_state: str
executed_at: datetime executed_at: datetime
class ModelSnapshot(BaseModel):
version_id: str
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
sample_count: int = Field(default=0, ge=0)
high_confidence_sample_count: int = Field(default=0, ge=0)
average_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
incorrect_feedback_count: int = Field(default=0, ge=0)
patterns: list[BehaviorPattern] = Field(default_factory=list)
reason: str = Field(default="", max_length=500)
class AutomationConflict(BaseModel):
automation_entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
severity: str = Field(default="info", max_length=20)
status: str = Field(default="open", max_length=40)
reason: str = Field(max_length=500)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class TimeProfile(BaseModel):
profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=80)
sample_count: int = Field(default=0, ge=0)
dominant_state: str | None = None
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
class RelatedAutomation(BaseModel): class RelatedAutomation(BaseModel):
entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$") entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
config_id: str = Field(min_length=1, max_length=120) config_id: str = Field(min_length=1, max_length=120)
@@ -139,6 +255,21 @@ class BehaviorState(BaseModel):
related_automations: list[RelatedAutomation] = Field(default_factory=list) related_automations: list[RelatedAutomation] = Field(default_factory=list)
paused_automation_entity_ids: list[str] = Field(default_factory=list) paused_automation_entity_ids: list[str] = Field(default_factory=list)
reason: str = "Historische Aktorhandlungen werden analysiert." 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)
model_snapshots: list[ModelSnapshot] = Field(default_factory=list)
active_model_version: str | None = None
adaptive_weight_updates: list[AdaptiveWeightUpdate] = Field(default_factory=list)
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
time_profiles: list[TimeProfile] = Field(default_factory=list)
class ActuatorRecord(BaseModel): class ActuatorRecord(BaseModel):
@@ -165,5 +296,22 @@ class ReconciliationState(BaseModel):
last_summary: str = "Noch keine Reconciliation ausgeführt." 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: def model_id_for_actuator(actuator_entity_id: str) -> str:
return f"actuator.{actuator_entity_id}" return f"actuator.{actuator_entity_id}"

View File

@@ -8,6 +8,9 @@ from threading import RLock
from app.actuators.models import ( from app.actuators.models import (
ActuatorRecord, ActuatorRecord,
JobQueueItem,
JobQueueState,
JobStatus,
LifecycleStatus, LifecycleStatus,
ModelLifecycleState, ModelLifecycleState,
ReconciliationState, ReconciliationState,
@@ -22,6 +25,7 @@ class ActuatorStore:
self._actuators_root.mkdir(parents=True, exist_ok=True) self._actuators_root.mkdir(parents=True, exist_ok=True)
self._lock = RLock() self._lock = RLock()
self._reconciliation_state_path = self._root / "reconciliation_state.json" self._reconciliation_state_path = self._root / "reconciliation_state.json"
self._job_queue_path = self._root / "job_queue.json"
def list(self) -> list[ActuatorRecord]: def list(self) -> list[ActuatorRecord]:
with self._lock: with self._lock:
@@ -85,6 +89,75 @@ class ActuatorStore:
self._persist_reconciliation_state(state) self._persist_reconciliation_state(state)
return 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: def _target(self, actuator_entity_id: str) -> Path:
if "." not in actuator_entity_id: if "." not in actuator_entity_id:
raise ValueError("Ungültige actuator_entity_id.") raise ValueError("Ungültige actuator_entity_id.")
@@ -108,6 +181,14 @@ class ActuatorStore:
) )
os.replace(temporary, self._reconciliation_state_path) 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 @staticmethod
def _load(path: Path) -> ActuatorRecord: def _load(path: Path) -> ActuatorRecord:
try: try:

View File

@@ -9,7 +9,8 @@ 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 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.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
@@ -44,11 +45,25 @@ class ManualAssignmentRequest(BaseModel):
note: str | None = Field(default=None, max_length=500) 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): class FeedbackRequest(BaseModel):
correct: bool correct: bool
expected_state: str | None = Field(default=None, max_length=100) expected_state: str | None = Field(default=None, max_length=100)
class SafetyProfileRequest(BaseModel):
safety: SafetyProfile
class ModelRollbackRequest(BaseModel):
version_id: str = Field(min_length=1, max_length=120)
class ActuatorSuggestion(BaseModel): class ActuatorSuggestion(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
@@ -106,6 +121,7 @@ class DashboardOverview(BaseModel):
cache: EntityCacheStatus cache: EntityCacheStatus
actuators: list[ActuatorSummary] actuators: list[ActuatorSummary]
discovery_groups: list[DashboardDiscoveryGroup] discovery_groups: list[DashboardDiscoveryGroup]
jobs: JobQueueState = Field(default_factory=JobQueueState)
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
@@ -118,8 +134,19 @@ def discover_actuators(
if cached_entities: if cached_entities:
entities = {entity.entity_id: entity for entity in cached_entities} entities = {entity.entity_id: entity for entity in cached_entities}
else: else:
fresh_entities = list(ha_reader.read_entities()) job = _start_job(
_save_cached_entities(request, fresh_entities) 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} entities = {entity.entity_id: entity for entity in fresh_entities}
discovered = discover_entities(list(entities.values())) discovered = discover_entities(list(entities.values()))
actuator_ids = _deduplicate_actuator_ids( actuator_ids = _deduplicate_actuator_ids(
@@ -272,6 +299,12 @@ def dashboard_overview(request: Request) -> DashboardOverview:
reconciliation = _reconciliation_state_or_default(request) reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None) ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request) 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( return DashboardOverview(
system=DashboardSystemStatus( system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"), websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -292,6 +325,7 @@ def dashboard_overview(request: Request) -> DashboardOverview:
), ),
actuators=actuators, actuators=actuators,
discovery_groups=cached_groups, discovery_groups=cached_groups,
jobs=jobs,
) )
@@ -366,6 +400,32 @@ def record_feedback(
raise HTTPException(status_code=404, detail=str(exc)) from exc 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}/model/rollback", response_model=ActuatorRecord)
def rollback_model(
actuator_entity_id: str,
payload: ModelRollbackRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).rollback_model(actuator_entity_id, version_id=payload.version_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
def set_activation( def set_activation(
actuator_entity_id: str, actuator_entity_id: str,
@@ -406,6 +466,27 @@ def set_manual_assignment(
raise HTTPException(status_code=422, detail=str(exc)) from exc 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( @router.post(
"/{actuator_entity_id}/related-automations/refresh", "/{actuator_entity_id}/related-automations/refresh",
response_model=ActuatorRecord, response_model=ActuatorRecord,
@@ -414,11 +495,27 @@ def refresh_related_automations(
actuator_entity_id: str, actuator_entity_id: str,
request: Request, request: Request,
) -> ActuatorRecord: ) -> ActuatorRecord:
job = _start_job(
request,
kind="automation_refresh",
trigger="manual",
target=actuator_entity_id,
summary="Passende HA-Automationen werden gesucht.",
)
try: 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: 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 raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ValueError, HaClientError) as 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 raise HTTPException(status_code=409, detail=str(exc)) from exc
@@ -459,12 +556,85 @@ def run_reconciliation(
request: Request, request: Request,
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"), trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
) -> ReconciliationState: ) -> ReconciliationState:
state = _service(request).reconcile_all(trigger=trigger) reconciliation_job = _start_job(
_behavior(request).train_all() request,
_behavior(request).evaluate_all() 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 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: 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):
@@ -485,6 +655,20 @@ def _behavior(request: Request) -> BehaviorEngine:
return engine 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: def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None) store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore): if not isinstance(store, ActuatorStore):

View File

@@ -7,13 +7,21 @@ from zoneinfo import ZoneInfo
from app.actuators.models import ( from app.actuators.models import (
ActuatorRecord, ActuatorRecord,
AdaptiveWeightUpdate,
AutomationConflict,
BehaviorMode, BehaviorMode,
BehaviorPattern, BehaviorPattern,
BehaviorPrediction, BehaviorPrediction,
BehaviorState, BehaviorState,
BehaviorStatus, BehaviorStatus,
DecisionFactor,
ExecutionEvent, ExecutionEvent,
ManualOverride,
ModelSnapshot,
RelatedAutomation, RelatedAutomation,
SafetyProfile,
SafetyStage,
TimeProfile,
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.config import Settings from app.config import Settings
@@ -165,6 +173,7 @@ class BehaviorEngine:
"eindeutig zugeordnete Handlungen fehlen." "eindeutig zugeordnete Handlungen fehlen."
) )
) )
model_version_id = f"model-{now.strftime('%Y%m%d%H%M%S')}"
behavior = record.behavior.model_copy( behavior = record.behavior.model_copy(
update={ update={
"status": status, "status": status,
@@ -175,6 +184,22 @@ class BehaviorEngine:
"patterns": patterns[-_MAX_PATTERNS:], "patterns": patterns[-_MAX_PATTERNS:],
"last_trained_at": now, "last_trained_at": now,
"reason": reason, "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),
"time_profiles": _time_profiles(patterns),
"model_snapshots": _next_model_snapshots(
record.behavior.model_snapshots,
model_version_id,
patterns[-_MAX_PATTERNS:],
len(patterns),
trusted_actions,
_average(record.behavior.confidence_trend),
record.behavior.incorrect_feedback_count,
reason,
),
"active_model_version": model_version_id,
} }
) )
return self._save_behavior(record, behavior) return self._save_behavior(record, behavior)
@@ -268,16 +293,25 @@ class BehaviorEngine:
timezone_name=self._settings.timezone, timezone_name=self._settings.timezone,
) )
if prediction is not None: if prediction is not None:
safety_allowed, safety_blockers = self._assess_safety(
record,
actuator.state,
prediction,
now,
)
prediction = prediction.model_copy( prediction = prediction.model_copy(
update={ update={
"execution_reason": self._prediction_execution_reason( "execution_reason": (
record, "Ausführung ist freigegeben."
actuator.state, if safety_allowed
prediction, else "Nicht ausgeführt: " + " ".join(safety_blockers)
now,
) )
} }
) )
else:
safety_allowed = False
safety_blockers = ["Keine fällige Vorhersage."]
decision_factors = _decision_factors_for(record, current_context, prediction)
behavior = record.behavior.model_copy( behavior = record.behavior.model_copy(
update={ update={
"last_evaluated_at": now, "last_evaluated_at": now,
@@ -287,18 +321,21 @@ class BehaviorEngine:
if prediction is not None if prediction is not None
else "Aktuell ist kein gelerntes Handlungsmuster fällig." 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 ( if (
prediction is not None prediction is not None
and behavior.mode is BehaviorMode.ACTIVE and safety_allowed
and prediction.confidence >= self._settings.prediction_confidence
and actuator.state != prediction.target_state
and self._cooldown_elapsed(
behavior,
now,
prediction.target_state,
)
): ):
domain = actuator_entity_id.split(".", 1)[0] domain = actuator_entity_id.split(".", 1)[0]
service = service_for_state(domain, prediction.target_state) service = service_for_state(domain, prediction.target_state)
@@ -403,6 +440,8 @@ class BehaviorEngine:
) )
) )
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt." 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: else:
target = prediction.target_state if prediction is not None else None target = prediction.target_state if prediction is not None else None
if target: if target:
@@ -431,6 +470,13 @@ class BehaviorEngine:
) )
) )
reason = "Vorhersage wurde vom Nutzer als falsch markiert." reason = "Vorhersage wurde vom Nutzer als falsch markiert."
correct_count = record.behavior.correct_feedback_count
incorrect_count = record.behavior.incorrect_feedback_count + 1
adaptive_updates, manual_override = _adapt_sensor_weights(
record,
current_context,
correct=correct,
)
behavior = record.behavior.model_copy( behavior = record.behavior.model_copy(
update={ update={
"patterns": patterns[-_MAX_PATTERNS:], "patterns": patterns[-_MAX_PATTERNS:],
@@ -441,6 +487,56 @@ class BehaviorEngine:
), ),
"reason": reason, "reason": reason,
"last_trained_at": now, "last_trained_at": now,
"correct_feedback_count": correct_count,
"incorrect_feedback_count": incorrect_count,
"adaptive_weight_updates": [
*record.behavior.adaptive_weight_updates,
*adaptive_updates,
][-50:],
}
)
record_for_save = (
record.model_copy(update={"manual_override": manual_override})
if manual_override is not None
else record
)
return self._save_behavior(record_for_save, behavior)
def rollback_model(
self,
actuator_entity_id: str,
*,
version_id: str,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
snapshot = next(
(item for item in record.behavior.model_snapshots if item.version_id == version_id),
None,
)
if snapshot is None:
raise ValueError("Modell-Snapshot nicht gefunden.")
behavior = record.behavior.model_copy(
update={
"patterns": snapshot.patterns,
"sample_count": snapshot.sample_count,
"high_confidence_sample_count": snapshot.high_confidence_sample_count,
"active_model_version": snapshot.version_id,
"reason": f"Rollback auf Modell-Snapshot {snapshot.version_id}.",
}
)
return self._save_behavior(record, behavior)
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) return self._save_behavior(record, behavior)
@@ -459,7 +555,10 @@ class BehaviorEngine:
) )
] ]
behavior = record.behavior.model_copy( behavior = record.behavior.model_copy(
update={"related_automations": related} update={
"related_automations": related,
"automation_conflicts": _automation_conflicts(record, related),
}
) )
return self._save_behavior(record, behavior) return self._save_behavior(record, behavior)
@@ -527,6 +626,9 @@ class BehaviorEngine:
update={ update={
"mode": mode, "mode": mode,
"approved_at": approved_at, "approved_at": approved_at,
"safety": record.behavior.safety.model_copy(
update={"stage": SafetyStage.ACTIVE, "updated_at": now}
),
"reason": ( "reason": (
"Autonomes Lernen und Schalten wurde ausdrücklich freigegeben." "Autonomes Lernen und Schalten wurde ausdrücklich freigegeben."
), ),
@@ -607,6 +709,9 @@ class BehaviorEngine:
update={ update={
"mode": mode, "mode": mode,
"approved_at": approved_at, "approved_at": approved_at,
"safety": record.behavior.safety.model_copy(
update={"stage": SafetyStage.SHADOW, "updated_at": now}
),
"related_automations": [ "related_automations": [
automation.model_copy(update={"enabled": True}) automation.model_copy(update={"enabled": True})
if ( if (
@@ -651,6 +756,51 @@ class BehaviorEngine:
return "Nicht ausgeführt: Sicherheits-Cooldown ist noch aktiv." return "Nicht ausgeführt: Sicherheits-Cooldown ist noch aktiv."
return "Ausführung ist freigegeben." 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( def _build_patterns(
self, self,
*, *,
@@ -701,6 +851,8 @@ class BehaviorEngine:
behavior: BehaviorState, behavior: BehaviorState,
now: datetime, now: datetime,
target_state: str, target_state: str,
*,
cooldown_seconds: int | None = None,
) -> bool: ) -> bool:
if behavior.last_executed_at is None: if behavior.last_executed_at is None:
return True return True
@@ -708,7 +860,9 @@ class BehaviorEngine:
if last_event is not None and last_event.target_state != target_state: if last_event is not None and last_event.target_state != target_state:
return True return True
return (now - behavior.last_executed_at) >= timedelta( 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( def _save_behavior(
@@ -791,6 +945,269 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
return parsed 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 _next_model_snapshots(
existing: list[ModelSnapshot],
version_id: str,
patterns: list[BehaviorPattern],
sample_count: int,
trusted_actions: int,
average_confidence: float,
incorrect_feedback_count: int,
reason: str,
) -> list[ModelSnapshot]:
snapshot = ModelSnapshot(
version_id=version_id,
sample_count=sample_count,
high_confidence_sample_count=trusted_actions,
average_confidence=round(average_confidence, 4),
incorrect_feedback_count=incorrect_feedback_count,
patterns=patterns,
reason=reason,
)
return [*existing, snapshot][-10:]
def _average(values: list[float]) -> float:
return sum(values) / len(values) if values else 0.0
def _time_profiles(patterns: list[BehaviorPattern]) -> list[TimeProfile]:
buckets = {
"night": ("Nacht", range(0, 360)),
"morning": ("Morgen", range(360, 720)),
"day": ("Tag", range(720, 1080)),
"evening": ("Abend", range(1080, 1440)),
}
profiles: list[TimeProfile] = []
for profile_id, (label, minutes) in buckets.items():
selected = [pattern for pattern in patterns if pattern.minute_of_day in minutes]
if not selected:
profiles.append(TimeProfile(profile_id=profile_id, label=label))
continue
by_state: dict[str, int] = {}
for pattern in selected:
by_state[pattern.target_state] = by_state.get(pattern.target_state, 0) + 1
dominant_state, count = max(by_state.items(), key=lambda item: (item[1], item[0]))
profiles.append(
TimeProfile(
profile_id=profile_id,
label=label,
sample_count=len(selected),
dominant_state=dominant_state,
confidence=round(count / len(selected), 4),
)
)
weekend = [pattern for pattern in patterns if pattern.weekday >= 5]
profiles.append(
TimeProfile(
profile_id="weekend",
label="Wochenende",
sample_count=len(weekend),
dominant_state=(
max(
{pattern.target_state: 0 for pattern in weekend},
key=lambda state: sum(pattern.target_state == state for pattern in weekend),
)
if weekend
else None
),
confidence=round(len(weekend) / len(patterns), 4) if patterns else 0.0,
)
)
return profiles
def _adapt_sensor_weights(
record: ActuatorRecord,
current_context: dict[str, str | None],
*,
correct: bool,
) -> tuple[list[AdaptiveWeightUpdate], ManualOverride | None]:
if not current_context:
return [], record.manual_override
candidates = {
candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
previous = record.manual_override
weights = dict(previous.sensor_weights if previous is not None else {})
updates: list[AdaptiveWeightUpdate] = []
delta = 0.03 if correct else -0.08
for entity_id in current_context:
candidate = candidates.get(entity_id)
base = weights.get(
entity_id,
candidate.effective_weight if candidate is not None else 1.0,
)
new_weight = round(min(1.0, max(0.1, base + delta)), 4)
if new_weight == base:
continue
weights[entity_id] = new_weight
updates.append(
AdaptiveWeightUpdate(
entity_id=entity_id,
previous_weight=round(base, 4),
new_weight=new_weight,
reason=(
"Feedback korrekt: Kontextsignal leicht höher gewichtet."
if correct
else "Feedback falsch: Kontextsignal vorsichtig abgewertet."
),
)
)
if not updates:
return [], previous
return updates, ManualOverride(
numeric_entity_id=(
previous.numeric_entity_id
if previous is not None
else record.assignment.selected_numeric_entity_id
),
context_entity_ids=(
previous.context_entity_ids
if previous is not None
else record.assignment.selected_context_entity_ids
),
sensor_weights=weights,
sensor_weight_groups=previous.sensor_weight_groups if previous is not None else [],
note="Sensor-Gewichtungen automatisch aus Feedback angepasst.",
)
def _automation_conflicts(
record: ActuatorRecord,
related: list[RelatedAutomation],
) -> list[AutomationConflict]:
conflicts: list[AutomationConflict] = []
for automation in related:
if record.behavior.mode is BehaviorMode.ACTIVE and automation.enabled:
conflicts.append(
AutomationConflict(
automation_entity_id=automation.entity_id,
severity="warning",
status="open",
reason=(
"SillyHome ist aktiv, aber diese passende HA-Automation "
"ist ebenfalls aktiv. Das kann zu konkurrierenden Schaltungen führen."
),
)
)
elif automation.entity_id in record.behavior.paused_automation_entity_ids:
conflicts.append(
AutomationConflict(
automation_entity_id=automation.entity_id,
severity="info",
status="controlled",
reason="Automation ist durch SillyHome pausiert.",
)
)
return conflicts
def predict_behavior( 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.0.2", version="1.2.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -25,6 +25,7 @@
--bad:#8fb8ff; --bad:#8fb8ff;
} }
* { box-sizing:border-box; } * { box-sizing:border-box; }
html, body { max-width:100%; overflow-x:hidden; }
body { margin:0; font-size:15px; background:var(--panel-quiet); } body { margin:0; font-size:15px; background:var(--panel-quiet); }
h1,h2,h3 { margin:0 0 10px; letter-spacing:0; } h1,h2,h3 { margin:0 0 10px; letter-spacing:0; }
p { margin:6px 0; } p { margin:6px 0; }
@@ -34,11 +35,10 @@
.brand-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } .brand-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.brand-mark { width:34px; height:34px; border-radius:8px; display:grid; place-items:center; background:var(--accent); color:#201204; font-weight:900; } .brand-mark { width:34px; height:34px; border-radius:8px; display:grid; place-items:center; background:var(--accent); color:#201204; font-weight:900; }
header p { color:var(--text-soft); max-width:820px; } header p { color:var(--text-soft); max-width:820px; }
.header-actions { display:grid; gap:8px; min-width:230px; }
.header-actions label { margin:0; font-size:.82rem; }
.status-pill { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#101722; color:#d9e6f0; white-space:nowrap; } .status-pill { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#101722; color:#d9e6f0; white-space:nowrap; }
.dot { width:9px; height:9px; border-radius:50%; background:var(--complement); box-shadow:0 0 0 3px rgba(28,199,255,.15); } .dot { width:9px; height:9px; border-radius:50%; background:var(--complement); box-shadow:0 0 0 3px rgba(28,199,255,.15); }
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 18px; background:rgba(14,18,24,.96); border-bottom:1px solid var(--border); backdrop-filter:blur(8px); }
.quick-nav a { flex:0 0 auto; min-height:36px; display:grid; place-items:center; padding:8px 12px; border-radius:8px; background:#151c25; border:1px solid var(--border); color:#f3f7fb; text-decoration:none; font-weight:750; font-size:.92rem; }
.quick-nav a.primary { background:var(--accent); border-color:var(--accent); color:#211204; }
main { display:grid; grid-template-columns:minmax(270px,.72fr) minmax(0,1.58fr); grid-template-areas:"control board" "control detail" "status status" "guide guide"; gap:12px; padding:12px; max-width:1480px; margin:0 auto; } main { display:grid; grid-template-columns:minmax(270px,.72fr) minmax(0,1.58fr); grid-template-areas:"control board" "control detail" "status status" "guide guide"; gap:12px; padding:12px; max-width:1480px; margin:0 auto; }
section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; } section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; }
section:target { outline:2px solid var(--complement); outline-offset:2px; } section:target { outline:2px solid var(--complement); outline-offset:2px; }
@@ -72,31 +72,38 @@
.bad { color: var(--bad); } .bad { color: var(--bad); }
label { display:block; margin:9px 0 4px; color:#c3d2df; font-weight:700; } 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; } 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; } 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 { 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.secondary { background:var(--complement-soft); color:#dff6ff; border:1px solid #22607c; }
button.danger { background:#2a3441; color:#f2f6fb; border:1px solid #536273; } 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; } 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; } 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; } 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; } ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left:4px solid var(--complement); padding-left:10px; } .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; } .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; } .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); } .muted { color:var(--text-soft); }
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:10px; } .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 { 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); } .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; } .card-title { display:flex; flex-wrap:wrap; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; min-width:0; }
.entity-id { overflow-wrap:anywhere; font-weight:800; } .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-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; } .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 { 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; }
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; } .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; } .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; } .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; } textarea { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
@@ -107,7 +114,8 @@
header { padding:16px 12px; } header { padding:16px 12px; }
header h1 { font-size:1.55rem; } header h1 { font-size:1.55rem; }
.topbar { display:grid; } .topbar { display:grid; }
.status-pill { width:max-content; } .header-actions { min-width:0; }
.status-pill { width:max-content; max-width:100%; white-space:normal; }
main { display:block; padding:8px; } main { display:block; padding:8px; }
.control-panel { position:static; } .control-panel { position:static; }
section { margin-bottom:10px; padding:10px; border-radius:8px; } section { margin-bottom:10px; padding:10px; border-radius:8px; }
@@ -117,8 +125,6 @@
.card-list { grid-template-columns:1fr; } .card-list { grid-template-columns:1fr; }
.actions { display:grid; grid-template-columns:1fr; } .actions { display:grid; grid-template-columns:1fr; }
.actions button, button.compact { width:100%; min-width:0; margin-right:0; } .actions button, button.compact { width:100%; min-width:0; margin-right:0; }
.quick-nav { padding:8px 10px; }
.quick-nav a { padding:10px 11px; }
} }
@media (max-width: 430px) { @media (max-width: 430px) {
.metric-grid { grid-template-columns:1fr; } .metric-grid { grid-template-columns:1fr; }
@@ -137,16 +143,19 @@
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p> <p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p>
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p> <p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
</div> </div>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Startdaten laden ...</span></div> <div class="header-actions">
<label for="section-jump">Menü</label>
<select id="section-jump" onchange="jumpToSection(this.value)">
<option value="#choose">Steuerung</option>
<option value="#observed">Geräte</option>
<option value="#detail">Freigabe</option>
<option value="#status-section">System</option>
<option value="#guide">Ablauf</option>
</select>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
</div>
</div> </div>
</header> </header>
<nav class="quick-nav" aria-label="Schnellnavigation">
<a class="primary" href="#choose">Steuerung</a>
<a href="#observed">Geräte</a>
<a href="#detail">Freigabe</a>
<a href="#status-section">System</a>
<a href="#guide">Ablauf</a>
</nav>
<main> <main>
<section class="control-panel" id="choose"> <section class="control-panel" id="choose">
<div class="panel-title"> <div class="panel-title">
@@ -227,6 +236,7 @@
<div id="status">Prüfung läuft ...</div> <div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div> <div class="chips" id="status-chips"></div>
<div id="dashboard-stats" class="metric-grid"></div> <div id="dashboard-stats" class="metric-grid"></div>
<div id="job-queue" class="decision-list"></div>
</section> </section>
<section class="guide-panel" id="guide"> <section class="guide-panel" id="guide">
@@ -270,10 +280,16 @@ let cachedActuators = null;
let cachedEntities = null; let cachedEntities = null;
let cachedDiscovery = null; let cachedDiscovery = null;
let discoveryLoadPromise = null; let discoveryLoadPromise = null;
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 = 4500;
function jumpToSection(target) {
if (!target) return;
document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"});
}
function uniqueValues(values) { function uniqueValues(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
@@ -445,17 +461,16 @@ async function loadStatus() {
const chips = document.getElementById("status-chips"); const chips = document.getElementById("status-chips");
status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>"; status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>";
try { try {
const [health, websocket, ml, reconciliation, actuators] = await Promise.allSettled([ const [health, websocket, ml, reconciliation] = await Promise.allSettled([
apiWithTimeout("health"), apiWithTimeout("health"),
apiWithTimeout("health/websocket"), apiWithTimeout("health/websocket"),
apiWithTimeout("ml/health"), apiWithTimeout("ml/health"),
apiWithTimeout("v1/actuators/reconciliation/state"), apiWithTimeout("v1/actuators/reconciliation/state"),
apiWithTimeout("v1/actuators/summary"),
]); ]);
const values = [health, websocket, ml, reconciliation, actuators].map(result => const values = [health, websocket, ml, reconciliation].map(result =>
result.status === "fulfilled" ? result.value : null result.status === "fulfilled" ? result.value : null
); );
const [healthValue, websocketValue, mlValue, reconciliationValue, actuatorValue] = values; const [healthValue, websocketValue, mlValue, reconciliationValue] = values;
const hasError = values.some(value => value === null); const hasError = values.some(value => value === null);
status.innerHTML = hasError status.innerHTML = hasError
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>" ? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
@@ -464,7 +479,6 @@ async function loadStatus() {
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`, `<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
`<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`, `<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`, `<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
`<span class="chip">Aktoren: ${Array.isArray(actuatorValue) ? actuatorValue.length : "offen"}</span>`,
`<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`, `<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
].join(""); ].join("");
} catch (error) { } catch (error) {
@@ -477,6 +491,7 @@ function renderDashboardStatus(dashboard) {
const status = document.getElementById("status"); const status = document.getElementById("status");
const chips = document.getElementById("status-chips"); const chips = document.getElementById("status-chips");
const stats = document.getElementById("dashboard-stats"); const stats = document.getElementById("dashboard-stats");
const jobsBox = document.getElementById("job-queue");
const system = dashboard.system || {}; const system = dashboard.system || {};
const cache = dashboard.cache || {}; const cache = dashboard.cache || {};
const actuators = dashboard.actuators || []; const actuators = dashboard.actuators || [];
@@ -489,6 +504,8 @@ 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 jobs = dashboard.jobs?.jobs || [];
const runningJobs = jobs.filter(job => job.status === "running").length;
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";
@@ -504,6 +521,7 @@ function renderDashboardStatus(dashboard) {
`<span class="chip">Aktoren: ${escapeHtml(system.configured_actuators ?? 0)}</span>`, `<span class="chip">Aktoren: ${escapeHtml(system.configured_actuators ?? 0)}</span>`,
`<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>`,
].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>`,
@@ -514,6 +532,21 @@ function renderDashboardStatus(dashboard) {
`<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 ? `
<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() { async function loadSummaryData() {
@@ -747,7 +780,7 @@ function renderConfiguredActuators() {
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)); const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
${groupedRows.map(([group, items]) => ` ${groupedRows.map(([group, items]) => `
<details class="group-panel" open> <details class="group-panel">
<summary>${escapeHtml(group)} (${items.length})</summary> <summary>${escapeHtml(group)} (${items.length})</summary>
<div class="card-list"> <div class="card-list">
${items.map(({record}) => ` ${items.map(({record}) => `
@@ -787,9 +820,10 @@ function renderConfiguredActuators() {
async function showActuator(actuatorId, evaluationMessage = "") { async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId; currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
renderActuatorDetailShell(actuatorId);
try { try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
await loadContextOptions(actuatorId); contextOptions = [];
const contexts = [ const contexts = [
record.assignment.selected_numeric_entity_id, record.assignment.selected_numeric_entity_id,
...record.assignment.selected_context_entity_ids, ...record.assignment.selected_context_entity_ids,
@@ -798,6 +832,62 @@ async function showActuator(actuatorId, evaluationMessage = "") {
.filter(candidate => contexts.includes(candidate.entity_id)) .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>`) .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .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 const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => ` ? `<ul>${contexts.map(entityId => `
<li> <li>
@@ -807,6 +897,113 @@ async function showActuator(actuatorId, evaluationMessage = "") {
`).join("")}</ul>` `).join("")}</ul>`
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>"; : "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
const prediction = record.behavior.prediction; 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 snapshots = record.behavior.model_snapshots || [];
const activeModelVersion = record.behavior.active_model_version || "";
const adaptiveUpdates = record.behavior.adaptive_weight_updates || [];
const automationConflicts = record.behavior.automation_conflicts || [];
const timeProfiles = record.behavior.time_profiles || [];
const safetyControls = `
<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 adaptivePanel = `
<details class="manual-context">
<summary>v1.2 Lernen, Rollback und Konflikte</summary>
<h3>Zeitprofile</h3>
<div class="metric-grid">
${timeProfiles.length ? timeProfiles.map(profile => `
<div class="metric">
<strong>${escapeHtml(profile.label)}</strong>
${escapeHtml(profile.sample_count)} Samples · ${escapeHtml(profile.dominant_state || "offen")}
<p class="muted">${Math.round((profile.confidence || 0) * 100)} % Profilklarheit</p>
</div>
`).join("") : "<div class='metric'><strong>Zeitprofile</strong>Noch keine Daten</div>"}
</div>
<h3>Modell-Snapshots</h3>
<div class="decision-list">
${snapshots.length ? snapshots.slice(-5).reverse().map(snapshot => `
<div class="decision-row">
<header>
<strong>${escapeHtml(snapshot.version_id)}</strong>
<span class="chip">${snapshot.version_id === activeModelVersion ? "aktiv" : "Rollback möglich"}</span>
</header>
<p class="muted">${escapeHtml(snapshot.sample_count)} Samples · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %</p>
<p>${escapeHtml(snapshot.reason || "Kein Kommentar")}</p>
${snapshot.version_id !== activeModelVersion ? `<button class="secondary compact" onclick="rollbackModel('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(snapshot.version_id)}')">Rollback</button>` : ""}
</div>
`).join("") : "<p class='muted'>Noch kein Modell-Snapshot gespeichert.</p>"}
</div>
<h3>Automatische Gewichtsanpassungen</h3>
<ul>${adaptiveUpdates.length ? adaptiveUpdates.slice(-8).reverse().map(update => `
<li><code>${escapeHtml(update.entity_id)}</code>: ${Math.round(update.previous_weight * 100)} % → ${Math.round(update.new_weight * 100)} %. ${escapeHtml(update.reason)}</li>
`).join("") : "<li>Noch keine automatische Gewichtsanpassung.</li>"}</ul>
<h3>Automation-Konflikte</h3>
<ul>${automationConflicts.length ? automationConflicts.map(conflict => `
<li><code>${escapeHtml(conflict.automation_entity_id)}</code>: <span class="${conflict.severity === "warning" ? "warn" : "muted"}">${escapeHtml(conflict.status)}</span> ${escapeHtml(conflict.reason)}</li>
`).join("") : "<li>Keine aktiven Automation-Konflikte erkannt.</li>"}</ul>
</details>
`;
const learnedAutomationActions = record.behavior.patterns.filter( const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation", pattern => pattern.source === "automation",
).length; ).length;
@@ -827,7 +1024,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
selected: manualContextIds, selected: manualContextIds,
}; };
const manualAssignment = ` const manualAssignment = `
<details class="manual-context" open> <details class="manual-context">
<summary>Kontext selbst festlegen</summary> <summary>Kontext selbst festlegen</summary>
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p> <p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label> <label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
@@ -856,7 +1053,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea> <textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
<div class="actions"> <div class="actions">
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button> <button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button> <button class="secondary" onclick="hydrateCurrentContextOptions('${escapeHtml(record.actuator_entity_id)}')">Vorschläge neu laden</button>
</div> </div>
</details> </details>
`; `;
@@ -916,22 +1113,117 @@ 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)}', true)">Vorhersage korrekt</button>
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button> <button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button>
</div> </div>
${safetyControls}
${decisionArchive}
${adaptivePanel}
<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>
${automationControls} ${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3> <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>"} ${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> <h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls} ${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
void hydrateContextOptions(record);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
} }
function renderActuatorDetailShell(actuatorId) {
document.getElementById("actuator-detail").innerHTML = `
<div class="detail-header">
<div>
<h3>${escapeHtml(actuatorId)}</h3>
<p class="muted">Basisdaten werden geladen ...</p>
</div>
</div>
<div class="metric-grid">
<div class="metric"><strong>Phase 1</strong>Aktuelle Einstellung</div>
<div class="metric"><strong>Phase 2</strong>Lernstand</div>
<div class="metric"><strong>Phase 3</strong>Kontextvorschläge</div>
</div>
`;
}
async function hydrateContextOptions(record) {
await loadContextOptions(record.actuator_entity_id);
const manualContextSelect = document.getElementById("manual-context-select");
const numericSelect = document.getElementById("manual-numeric-select");
const categorySelect = document.getElementById("manual-context-category");
if (!manualContextSelect || !numericSelect || !categorySelect) return;
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
const contextCategories = [...new Set(contextOptions
.filter(entity => entity.entity_id !== record.actuator_entity_id)
.map(categoryForEntity))]
.sort();
manualContextState = {
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
selected: manualContextIds,
};
numericSelect.innerHTML = `
<option value="">Keinen numerischen Hauptsensor verwenden</option>
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
`;
categorySelect.innerHTML = `
<option value="">Alle relevanten Vorschläge</option>
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
`;
renderManualContextSelect();
}
async function hydrateCurrentContextOptions(actuatorId) {
const record = await api(`v1/actuators/${encodeURIComponent(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) { async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null; const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from( const selectedContextIds = Array.from(
@@ -1026,6 +1318,51 @@ 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 rollbackModel(actuatorId, versionId) {
if (!confirm(`${actuatorId}: wirklich auf Modell ${versionId} zurückrollen?`)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/model/rollback`, {
method: "POST",
body: JSON.stringify({version_id: versionId}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, `Rollback auf ${versionId} ausgeführt.`);
} catch (error) {
alert(error.message);
}
}
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) { async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
const question = active const question = active
? pauseMatchingAutomations ? pauseMatchingAutomations
@@ -1096,7 +1433,16 @@ async function removeActuator(actuatorId) {
} }
} }
loadOverview(); async function startDashboard() {
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Geräte werden nach dem Status geladen.</div>";
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
await new Promise(resolve => requestAnimationFrame(resolve));
await loadStatus();
await loadOverview();
}
void startDashboard();
</script> </script>
</body> </body>
</html> </html>

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 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 ## Rollback
Der stabile Vor-1.0-Stand ist `v0.7.21`. Vor dem 1.0.0-Umbau wurde ein 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 .` - `ruff check .`
- `mypy app backend tests` - `mypy app backend tests`
- `git diff --check` - `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 ## Teilweise Erfuellt
@@ -62,14 +68,10 @@ expliziter Freigabe.
## Offen Fuer v1.0.x ## 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 - Dashboard-Jobstatus fuer Reconciliation, Training, Discovery und
Automation-Refresh. Automation-Refresh.
- Mehr Entscheidungsstatistik pro Aktor: welche Sensoren wie stark - Mehr Entscheidungsstatistik pro Aktor: welche Sensoren wie stark
beigetragen haben, wie sich Confidence und Sample Count entwickeln. 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 ## 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

@@ -0,0 +1,62 @@
# SillyHome Next v1.2.0 Operating Guide
## Ziel
v1.2.0 erweitert die sichere v1.1-Grundlage um adaptive Lernfunktionen. Diese
Funktionen laufen bei Feedback, Training oder Automation-Refresh und blockieren
nicht den direkten Schaltpfad.
## Adaptive Gewichtung
Feedback passt die Gewichtung aktuell beteiligter Kontextsignale vorsichtig an:
- korrektes Feedback: +3 Prozentpunkte bis maximal 100 %
- falsches Feedback: -8 Prozentpunkte bis minimal 10 %
Die Aenderungen werden als `adaptive_weight_updates` gespeichert und im
Dashboard angezeigt. Manuelle Gewichtungen bleiben weiter direkt korrigierbar.
## Modell-Snapshots und Rollback
Bei jedem Training wird ein Snapshot gespeichert:
- Version-ID
- Sample Count
- eindeutig zugeordnete Handlungen
- durchschnittliche Confidence
- negative Feedbacks
- Musterliste
- Begruendung
Ueber das Dashboard kann auf einen frueheren Snapshot zurueckgerollt werden.
## Automation-Konflikte
Beim Automation-Refresh markiert SillyHome Konflikte, wenn:
- SillyHome fuer einen Aktor aktiv ist
- eine passende Home-Assistant-Automation ebenfalls aktiv bleibt
Pausierte Automationen werden als kontrolliert markiert.
## Zeitprofile
SillyHome bildet Profile fuer:
- Nacht
- Morgen
- Tag
- Abend
- Wochenende
Diese Profile zeigen Sample Count, dominanten Zielzustand und Profilklarheit.
## Performance-Grenze
v1.2-Funktionen duerfen den Schaltmoment nicht verlangsamen. Der direkte
Schaltpfad bleibt:
1. vorhandene aktuelle States nutzen
2. lokale Safety-Pruefung
3. direkter Home-Assistant-Serviceaufruf
4. Persistenz der Entscheidung

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "1.0.2" version = "1.2.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,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
from time import perf_counter
from datetime import datetime, timedelta from datetime import datetime, timedelta
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.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
@@ -29,6 +31,7 @@ class FakeHaReader(HaReader):
self._entities = entities self._entities = entities
self._history = history self._history = history
self.read_entities_calls = 0 self.read_entities_calls = 0
self.service_calls: list[tuple[str, str, dict[str, object]]] = []
def read_entities(self) -> list[HaEntitySummary]: def read_entities(self) -> list[HaEntitySummary]:
self.read_entities_calls += 1 self.read_entities_calls += 1
@@ -85,6 +88,7 @@ class FakeHaReader(HaReader):
service: str, service: str,
service_data: dict[str, object], service_data: dict[str, object],
) -> list[object]: ) -> list[object]:
self.service_calls.append((domain, service, service_data))
return [] return []
def find_automations_for_entity( def find_automations_for_entity(
@@ -110,6 +114,7 @@ def _install_service(tmp_path: Path) -> None:
unit_of_measurement="lx", unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit", friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer", area_name="Abstellkammer",
state="12",
), ),
HaEntitySummary( HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion", entity_id="binary_sensor.abstellkammer_motion",
@@ -117,6 +122,7 @@ def _install_service(tmp_path: Path) -> None:
device_class="motion", device_class="motion",
friendly_name="Abstellkammer Bewegung", friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer", area_name="Abstellkammer",
state="off",
), ),
HaEntitySummary( HaEntitySummary(
entity_id="sensor.pfsense_interface_vpn_inbytes", entity_id="sensor.pfsense_interface_vpn_inbytes",
@@ -215,6 +221,136 @@ 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_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post(
"/v1/actuators",
json={"actuator_entity_id": "light.abstellkammer"},
)
record = app.state.actuator_store.get("light.abstellkammer")
version_id = "model-test"
snapshot = ModelSnapshot(
version_id=version_id,
sample_count=1,
high_confidence_sample_count=1,
average_confidence=0.9,
patterns=[],
reason="Test-Snapshot",
)
app.state.actuator_store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"model_snapshots": [snapshot],
"active_model_version": "model-current",
"sample_count": 2,
}
)
}
)
)
feedback = client.post(
"/v1/actuators/light.abstellkammer/feedback",
json={"correct": False, "expected_state": "off"},
)
rollback = client.post(
"/v1/actuators/light.abstellkammer/model/rollback",
json={"version_id": version_id},
)
assert feedback.status_code == 200
feedback_payload = feedback.json()
assert feedback_payload["behavior"]["adaptive_weight_updates"]
assert feedback_payload["manual_override"]["sensor_weights"]
assert rollback.status_code == 200
assert rollback.json()["behavior"]["active_model_version"] == version_id
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None: def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
@@ -249,6 +385,46 @@ def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> N
assert payload["cache"]["entity_count"] == 4 assert payload["cache"]["entity_count"] == 4
assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht" assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
assert payload["discovery_groups"] 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: def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None: