From b9b5def7bbb9ba3b8cf8dae06397827304b85795 Mon Sep 17 00:00:00 2001 From: Otto Date: Wed, 17 Jun 2026 11:41:46 +0200 Subject: [PATCH] Add actuator sensor weighting controls --- CHANGELOG.md | 9 +++ addon/config.yaml | 2 +- app/actuators/lifecycle.py | 128 +++++++++++++++++++++++++++++++++--- app/actuators/models.py | 11 ++++ app/api/v1/actuators.py | 43 +++++++++++- app/main.py | 2 +- app/static/index.html | 100 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/api/test_actuators.py | 48 ++++++++++++++ 9 files changed, 331 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f52e1..844637e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 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. diff --git a/addon/config.yaml b/addon/config.yaml index c87853b..ada1e9e 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.0.3" +version: "1.0.4" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/actuators/lifecycle.py b/app/actuators/lifecycle.py index 4fc7f6f..548a88d 100644 --- a/app/actuators/lifecycle.py +++ b/app/actuators/lifecycle.py @@ -16,6 +16,7 @@ from app.actuators.models import ( ManualOverride, ModelLifecycleState, ReconciliationState, + SensorWeightGroup, model_id_for_actuator, ) from app.actuators.store import ActuatorStore @@ -239,6 +240,10 @@ class ActuatorReconciliationService: override = ManualOverride( numeric_entity_id=numeric_entity_id, context_entity_ids=selected_context_ids, + sensor_weights=record.manual_override.sensor_weights if record.manual_override else {}, + sensor_weight_groups=( + record.manual_override.sensor_weight_groups if record.manual_override else [] + ), updated_at=now, note=note, ) @@ -253,17 +258,23 @@ class ActuatorReconciliationService: update={ "assignment": assignment, "manual_override": override, - "numeric_candidates": _merge_manual_candidates( - record.numeric_candidates, - entities, - [numeric_entity_id] if numeric_entity_id else [], - role=EntityRole.MEASUREMENT, + "numeric_candidates": _apply_weight_overrides( + _merge_manual_candidates( + record.numeric_candidates, + entities, + [numeric_entity_id] if numeric_entity_id else [], + role=EntityRole.MEASUREMENT, + ), + override, ), - "context_candidates": _merge_manual_candidates( - record.context_candidates, - entities, - selected_context_ids, - role=EntityRole.CONTEXT, + "context_candidates": _apply_weight_overrides( + _merge_manual_candidates( + record.context_candidates, + entities, + selected_context_ids, + role=EntityRole.CONTEXT, + ), + override, ), "lifecycle": lifecycle, "updated_at": now, @@ -271,6 +282,65 @@ class ActuatorReconciliationService: ) return self._store.upsert(updated) + def set_weight_overrides( + self, + actuator_entity_id: str, + *, + sensor_weights: dict[str, float], + sensor_weight_groups: list[SensorWeightGroup], + note: str | None = None, + ) -> ActuatorRecord: + now = datetime.now(timezone.utc) + record = self._store.get(actuator_entity_id) + selected_ids = { + entity_id + for entity_id in [ + record.assignment.selected_numeric_entity_id, + *record.assignment.selected_context_entity_ids, + ] + if entity_id + } + selected_ids.update(sensor_weights) + for group in sensor_weight_groups: + selected_ids.update(group.entity_ids) + entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} + missing = [entity_id for entity_id in selected_ids if entity_id not in entities] + if missing: + raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(sorted(missing))}") + + previous = record.manual_override + override = ManualOverride( + numeric_entity_id=( + previous.numeric_entity_id + if previous is not None + else record.assignment.selected_numeric_entity_id + ), + context_entity_ids=( + previous.context_entity_ids + if previous is not None + else record.assignment.selected_context_entity_ids + ), + sensor_weights={entity_id: round(weight, 4) for entity_id, weight in sensor_weights.items()}, + sensor_weight_groups=sensor_weight_groups, + updated_at=now, + note=note, + ) + updated = record.model_copy( + update={ + "manual_override": override, + "numeric_candidates": _apply_weight_overrides( + record.numeric_candidates, + override, + ), + "context_candidates": _apply_weight_overrides( + record.context_candidates, + override, + ), + "updated_at": now, + } + ) + return self._store.upsert(updated) + def reconcile_all(self, trigger: str = "manual") -> ReconciliationState: state = self._store.load_reconciliation_state().model_copy( update={ @@ -376,6 +446,9 @@ class ActuatorReconciliationService: ), context=True, ) + if record.manual_override is not None: + numeric_candidates = _apply_weight_overrides(numeric_candidates, record.manual_override) + context_candidates = _apply_weight_overrides(context_candidates, record.manual_override) assignment = ( self._manual_assignment(record.manual_override) if record.manual_override is not None @@ -918,6 +991,41 @@ def _merge_manual_candidates( return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id)) +def _apply_weight_overrides( + candidates: list[AssignmentCandidate], + override: ManualOverride, +) -> list[AssignmentCandidate]: + if not override.sensor_weights and not override.sensor_weight_groups: + return candidates + group_weights: dict[str, float] = {} + for group in override.sensor_weight_groups: + for entity_id in group.entity_ids: + group_weights[entity_id] = max(group_weights.get(entity_id, 0.0), group.weight) + weighted: list[AssignmentCandidate] = [] + for candidate in candidates: + explicit = override.sensor_weights.get(candidate.entity_id) + group_weight = group_weights.get(candidate.entity_id) + manual_weight = explicit if explicit is not None else group_weight + effective_weight = manual_weight if manual_weight is not None else 1.0 + evidence = [ + item + for item in candidate.evidence + if not item.startswith("Manuelle Gewichtung:") + ] + if manual_weight is not None: + evidence.append(f"Manuelle Gewichtung: {round(manual_weight * 100)} %.") + weighted.append( + candidate.model_copy( + update={ + "manual_weight": manual_weight, + "effective_weight": round(effective_weight, 4), + "evidence": evidence, + } + ) + ) + return sorted(weighted, key=lambda item: (-item.confidence * item.effective_weight, item.entity_id)) + + def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: if context: mapping = { diff --git a/app/actuators/models.py b/app/actuators/models.py index b1faee5..a4f4dc8 100644 --- a/app/actuators/models.py +++ b/app/actuators/models.py @@ -49,6 +49,8 @@ class AssignmentCandidate(BaseModel): device_name: str | None = None score: float = Field(ge=0.0) confidence: float = Field(ge=0.0, le=1.0) + manual_weight: float | None = Field(default=None, ge=0.0, le=1.0) + effective_weight: float = Field(default=1.0, ge=0.0, le=1.0) auto_accepted: bool = False evidence: list[str] = Field(default_factory=list) @@ -62,9 +64,18 @@ class AssignmentSelection(BaseModel): reason: str = "Noch keine Zuordnung vorhanden." +class SensorWeightGroup(BaseModel): + group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$") + name: str = Field(min_length=1, max_length=120) + entity_ids: list[str] = Field(default_factory=list) + weight: float = Field(default=1.0, ge=0.0, le=1.0) + + class ManualOverride(BaseModel): numeric_entity_id: str | None = None context_entity_ids: list[str] = Field(default_factory=list) + sensor_weights: dict[str, float] = Field(default_factory=dict) + sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) note: str | None = None diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 989cc04..79f3c94 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, Field from app.actuators.lifecycle import ActuatorReconciliationService -from app.actuators.models import ActuatorRecord, ReconciliationState +from app.actuators.models import ActuatorRecord, ReconciliationState, SensorWeightGroup from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine from app.config import Settings @@ -44,6 +44,12 @@ class ManualAssignmentRequest(BaseModel): note: str | None = Field(default=None, max_length=500) +class WeightOverrideRequest(BaseModel): + sensor_weights: dict[str, float] = Field(default_factory=dict) + sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list) + note: str | None = Field(default=None, max_length=500) + + class FeedbackRequest(BaseModel): correct: bool expected_state: str | None = Field(default=None, max_length=100) @@ -406,6 +412,27 @@ def set_manual_assignment( raise HTTPException(status_code=422, detail=str(exc)) from exc +@router.post("/{actuator_entity_id}/weights", response_model=ActuatorRecord) +def set_weight_overrides( + actuator_entity_id: str, + payload: WeightOverrideRequest, + request: Request, +) -> ActuatorRecord: + try: + _validate_weight_payload(payload) + record = _service(request).set_weight_overrides( + actuator_entity_id, + sensor_weights=payload.sensor_weights, + sensor_weight_groups=payload.sensor_weight_groups, + note=payload.note, + ) + return record + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + @router.post( "/{actuator_entity_id}/related-automations/refresh", response_model=ActuatorRecord, @@ -485,6 +512,20 @@ def _behavior(request: Request) -> BehaviorEngine: return engine +def _validate_weight_payload(payload: WeightOverrideRequest) -> None: + for entity_id, weight in payload.sensor_weights.items(): + if "." not in entity_id: + raise ValueError(f"Ungültige Entity-ID: {entity_id}") + if not 0.0 <= weight <= 1.0: + raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}") + for group in payload.sensor_weight_groups: + if not group.entity_ids: + raise ValueError(f"Gruppe {group.name} enthält keine Entities.") + for entity_id in group.entity_ids: + if "." not in entity_id: + raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}") + + def _reconciliation_state_or_default(request: Request) -> ReconciliationState: store = getattr(request.app.state, "actuator_store", None) if not isinstance(store, ActuatorStore): diff --git a/app/main.py b/app/main.py index 49b4af6..2d1d3d0 100644 --- a/app/main.py +++ b/app/main.py @@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="1.0.3", + version="1.0.4", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 204ddf4..0237e15 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -271,6 +271,7 @@ let cachedActuators = null; let cachedEntities = null; let cachedDiscovery = null; let discoveryLoadPromise = null; +let currentSensorWeightGroups = []; const ACTUATOR_RESULT_LIMIT = 50; const STATUS_TIMEOUT_MS = 2000; const DASHBOARD_TIMEOUT_MS = 4500; @@ -803,6 +804,62 @@ async function showActuator(actuatorId, evaluationMessage = "") { .filter(candidate => contexts.includes(candidate.entity_id)) .map(candidate => `
  • ${escapeHtml(candidate.friendly_name || candidate.entity_id)}: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}
  • `) .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 ? ` +
    + ${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 ` +
    +
    +
    +
    ${escapeHtml(candidate.friendly_name || candidate.entity_id)}
    +
    ${escapeHtml(candidate.entity_id)}
    +
    + ${relevance} % relevant +
    +
    +
    Automatische Relevanz${relevance} %
    +
    Aktive Gewichtung${effective} %
    +
    Score${escapeHtml(candidate.score)}
    +
    + + +
    + `; + }).join("")} +
    +
    + +
    + ` : "

    Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.

    "; + const weightGroupControls = ` +
    + Gruppen-Gewichtung + ${weightGroups.length ? `` : "

    Noch keine Gruppe gespeichert.

    "} +
    +
    + + +
    +
    + + +
    +
    + + + +
    + `; const currentContextControls = contexts.length ? `