Compare commits

..

1 Commits

Author SHA1 Message Date
47e8c7e549 ASSIGN-001: reject unrelated actuator sensors 2026-06-14 15:19:30 +02:00
4 changed files with 84 additions and 7 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## 0.5.3 - 2026-06-14
- Verhindert fachlich falsche Sensorzuordnungen nur aufgrund generischer Namen wie
`Licht` oder `Lichtschalter`
- Übernimmt numerische Sensoren nur noch bei einem belastbaren absoluten Score und
einer eindeutigen Abgrenzung zum zweitbesten Kandidaten
- Begrenzt Zusatzkontext auf relevante Sensoren und bevorzugt bei Lichtaktoren
echte Beleuchtungsstärke gegenüber fremden Leistungs- oder Energiezählern
## 0.5.2 - 2026-06-14
- Add-on-Build invalidiert den Docker-Cache bei jeder Versionsänderung, damit
Versionsmetadaten und tatsächlich ausgelieferter Anwendungscode übereinstimmen

View File

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

View File

@@ -45,6 +45,8 @@ _STOPWORDS = frozenset(
"humidity",
"illuminance",
"light",
"licht",
"lichtschalter",
"power",
"sensor",
"state",
@@ -54,8 +56,10 @@ _STOPWORDS = frozenset(
}
)
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18
_CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
_MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20
@@ -231,10 +235,14 @@ class ActuatorReconciliationService:
numeric_candidates: list[AssignmentCandidate],
context_candidates: list[AssignmentCandidate],
) -> AssignmentSelection:
top_numeric = numeric_candidates[0] if numeric_candidates else None
top_numeric = next(
(candidate for candidate in numeric_candidates if candidate.auto_accepted),
None,
)
top_contexts = [
candidate.entity_id
for candidate in context_candidates
if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS]
if top_numeric is None:
return AssignmentSelection(
@@ -433,8 +441,15 @@ class ActuatorReconciliationService:
confidence = candidate.score / highest if highest else 0.0
margin = candidate.score - second_score if index == 0 else 0.0
auto_score = _CONTEXT_AUTO_ACCEPT_SCORE if context else _NUMERIC_AUTO_ACCEPT_SCORE
auto_accepted = confidence >= auto_score and (
context or margin >= _NUMERIC_MIN_MARGIN
minimum_score = (
_CONTEXT_AUTO_ACCEPT_MIN_SCORE
if context
else _NUMERIC_AUTO_ACCEPT_MIN_SCORE
)
auto_accepted = (
candidate.score >= minimum_score
and confidence >= auto_score
and (context or margin >= _NUMERIC_MIN_MARGIN)
)
sorted_candidates[index] = candidate.model_copy(
update={
@@ -510,6 +525,9 @@ def _score_candidate(
if entity.device_class in preferred_device_classes:
score += 0.2
evidence.append(f"Passende device_class: {entity.device_class}")
if not context and actuator.domain == "light" and entity.device_class == "illuminance":
score += 0.2
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
if not context and entity.unit_of_measurement is not None:
score += 0.05
evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}")

View File

@@ -141,7 +141,7 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Path) -> None:
def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
@@ -181,8 +181,59 @@ def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Pat
record = service.configure_actuator("switch.garage_pump")
assert record.assignment.review_required is True
assert record.assignment.selected_numeric_entity_id == "sensor.garage_energy"
assert record.lifecycle.status is LifecycleStatus.TRAINED
assert record.assignment.selected_numeric_entity_id is None
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_reconciliation_does_not_cross_assign_other_room_light_energy(
tmp_path: Path,
) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id=(
"light.lichtschalter_abstellraum_"
"lichtschalter_abstellraum_s1"
),
domain="light",
friendly_name="Licht Abstellraum",
),
HaEntitySummary(
entity_id="sensor.licht_badezimmer_energy",
domain="sensor",
device_class="energy",
state_class="total_increasing",
unit_of_measurement="kWh",
friendly_name="Lichtschalter_Badezimmer Licht Badezimmer energy",
),
HaEntitySummary(
entity_id="binary_sensor.abstellraum_ture",
domain="binary_sensor",
device_class="door",
friendly_name="Abstellraum Türe",
),
HaEntitySummary(
entity_id="binary_sensor.briefkasten_open",
domain="binary_sensor",
device_class="opening",
friendly_name="Briefkasten open",
),
]
service = _service(
tmp_path,
entities,
{"sensor.licht_badezimmer_energy": _points(8, start, 1.0)},
)
record = service.configure_actuator(
"light.lichtschalter_abstellraum_lichtschalter_abstellraum_s1"
)
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.abstellraum_ture"
]
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None: