Improve learning discovery and dashboard i18n
This commit is contained in:
@@ -46,6 +46,8 @@ _STOPWORDS = frozenset(
|
||||
"entity",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"led",
|
||||
"lidl",
|
||||
"light",
|
||||
"licht",
|
||||
"lichtschalter",
|
||||
@@ -138,6 +140,33 @@ _AUTO_CONTEXT_CLASSES = frozenset({
|
||||
"presence",
|
||||
"window",
|
||||
})
|
||||
_PRESENCE_TOKENS = frozenset({
|
||||
"besetzt",
|
||||
"occupied",
|
||||
"occupancy",
|
||||
"presence",
|
||||
"prasenz",
|
||||
"praesenz",
|
||||
"motion",
|
||||
"bewegung",
|
||||
"bewegungsmelder",
|
||||
})
|
||||
_MAILBOX_TOKENS = frozenset({"briefkasten", "mailbox", "post"})
|
||||
_CABINET_TOKENS = frozenset({"schrank", "cabinet"})
|
||||
_PV_TOKENS = frozenset({
|
||||
"pv",
|
||||
"solar",
|
||||
"photovoltaik",
|
||||
"akku",
|
||||
"batterie",
|
||||
"battery",
|
||||
"einspeisung",
|
||||
"wechselrichter",
|
||||
"inverter",
|
||||
"netzbezug",
|
||||
"grid",
|
||||
"verbrauch",
|
||||
})
|
||||
|
||||
|
||||
class ActuatorReconciliationService:
|
||||
@@ -864,6 +893,18 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
|
||||
return True
|
||||
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
|
||||
return True
|
||||
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
|
||||
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||
if _is_mailbox_reset_candidate(actuator_tokens, entity_tokens, entity):
|
||||
return True
|
||||
if actuator.domain in {"fan", "humidifier"} and (
|
||||
_is_presence_context(entity) or entity.device_class in {"humidity", "moisture"}
|
||||
):
|
||||
return True
|
||||
if actuator.domain in {"climate", "cover", "fan", "humidifier", "light", "switch"} and (
|
||||
entity_tokens.intersection(_PV_TOKENS)
|
||||
):
|
||||
return True
|
||||
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||
return bool(
|
||||
entity_tokens.intersection(_OUTDOOR_TOKENS)
|
||||
@@ -878,6 +919,18 @@ def _eligible_for_auto_context(
|
||||
device_class = candidate.device_class or ""
|
||||
if device_class in _AUTO_CONTEXT_CLASSES:
|
||||
return True
|
||||
if actuator.domain in {"fan", "humidifier"} and device_class in {
|
||||
"humidity",
|
||||
"moisture",
|
||||
"temperature",
|
||||
}:
|
||||
return True
|
||||
if actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_candidate(candidate):
|
||||
return True
|
||||
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
|
||||
candidate_tokens = _candidate_tokens(candidate, include_stopwords=True)
|
||||
if _is_mailbox_reset_candidate(actuator_tokens, candidate_tokens, candidate):
|
||||
return True
|
||||
if (
|
||||
actuator.device_name
|
||||
and candidate.device_name
|
||||
@@ -899,6 +952,7 @@ def _score_candidate(
|
||||
score = 0.0
|
||||
actuator_tokens = _metadata_tokens(actuator)
|
||||
entity_tokens = _metadata_tokens(entity)
|
||||
full_entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||
overlap = sorted(actuator_tokens.intersection(entity_tokens))
|
||||
if overlap:
|
||||
score += min(0.4, 0.1 * len(overlap))
|
||||
@@ -924,6 +978,31 @@ def _score_candidate(
|
||||
if entity.device_class in preferred_device_classes:
|
||||
score += 0.2
|
||||
evidence.append(f"Passende device_class: {entity.device_class}")
|
||||
if context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
|
||||
"humidity",
|
||||
"moisture",
|
||||
}:
|
||||
score += 0.3
|
||||
evidence.append("Luftfeuchtigkeit ist primärer Kontext für Lüftung.")
|
||||
if not context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
|
||||
"humidity",
|
||||
"moisture",
|
||||
}:
|
||||
score += 0.3
|
||||
evidence.append("Luftfeuchtigkeit ist primärer Messwert für Lüftung.")
|
||||
if context and actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_context(entity):
|
||||
score += 0.3
|
||||
evidence.append("Anwesenheit/Belegung ist primärer Schaltkontext.")
|
||||
if context and _is_mailbox_reset_candidate(
|
||||
_metadata_tokens(actuator, include_stopwords=True),
|
||||
_metadata_tokens(entity, include_stopwords=True),
|
||||
entity,
|
||||
):
|
||||
score += 0.45
|
||||
evidence.append("Briefkasten-Reset passt zur Schrank-/Entnahme-Tür.")
|
||||
if full_entity_tokens.intersection(_PV_TOKENS):
|
||||
score += 0.12 if context else 0.18
|
||||
evidence.append("PV-/Akku-/Verbrauchswert ist als Energiemanagement-Kontext relevant.")
|
||||
if not context and actuator.domain == "light" and entity.device_class == "illuminance":
|
||||
score += 0.2
|
||||
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
|
||||
@@ -1068,11 +1147,83 @@ def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False
|
||||
for value in raw_values:
|
||||
if value is None:
|
||||
continue
|
||||
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
|
||||
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
|
||||
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
|
||||
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
|
||||
continue
|
||||
tokens.add(token)
|
||||
return tokens
|
||||
return _expand_room_tokens(tokens)
|
||||
|
||||
|
||||
def _candidate_tokens(
|
||||
candidate: AssignmentCandidate,
|
||||
*,
|
||||
include_stopwords: bool = False,
|
||||
) -> set[str]:
|
||||
raw_values = [
|
||||
candidate.entity_id,
|
||||
candidate.friendly_name,
|
||||
candidate.area_name,
|
||||
candidate.device_name,
|
||||
]
|
||||
tokens: set[str] = set()
|
||||
for value in raw_values:
|
||||
if value is None:
|
||||
continue
|
||||
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
|
||||
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
|
||||
continue
|
||||
tokens.add(token)
|
||||
return _expand_room_tokens(tokens)
|
||||
|
||||
|
||||
def _expand_room_tokens(tokens: set[str]) -> set[str]:
|
||||
expanded = set(tokens)
|
||||
if "gaste" in expanded:
|
||||
expanded.add("gaeste")
|
||||
if {"gaste", "wc"}.issubset(expanded) or {"gaeste", "wc"}.issubset(expanded):
|
||||
expanded.add("gaestewc")
|
||||
if {"gaeste", "zimmer"}.issubset(expanded):
|
||||
expanded.add("gaestezimmer")
|
||||
return expanded
|
||||
|
||||
|
||||
def _normalize_text(value: str) -> str:
|
||||
return (
|
||||
value.lower()
|
||||
.replace("_", " ")
|
||||
.replace("ä", "ae")
|
||||
.replace("ö", "oe")
|
||||
.replace("ü", "ue")
|
||||
.replace("ß", "ss")
|
||||
)
|
||||
|
||||
|
||||
def _is_presence_context(entity: HaEntitySummary) -> bool:
|
||||
if entity.device_class in {"motion", "occupancy", "presence"}:
|
||||
return True
|
||||
return bool(_metadata_tokens(entity, include_stopwords=True).intersection(_PRESENCE_TOKENS))
|
||||
|
||||
|
||||
def _is_presence_candidate(candidate: AssignmentCandidate) -> bool:
|
||||
if candidate.device_class in {"motion", "occupancy", "presence"}:
|
||||
return True
|
||||
return bool(_candidate_tokens(candidate, include_stopwords=True).intersection(_PRESENCE_TOKENS))
|
||||
|
||||
|
||||
def _is_mailbox_reset_candidate(
|
||||
actuator_tokens: set[str],
|
||||
context_tokens: set[str],
|
||||
entity: HaEntitySummary | AssignmentCandidate,
|
||||
) -> bool:
|
||||
if not actuator_tokens.intersection(_MAILBOX_TOKENS):
|
||||
return False
|
||||
if not context_tokens.intersection(_CABINET_TOKENS):
|
||||
return False
|
||||
return entity.domain == "binary_sensor" and entity.device_class in {
|
||||
"door",
|
||||
"garage_door",
|
||||
"opening",
|
||||
}
|
||||
|
||||
|
||||
def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str:
|
||||
|
||||
@@ -123,12 +123,14 @@ class ModelLifecycleState(BaseModel):
|
||||
|
||||
class BehaviorPattern(BaseModel):
|
||||
target_state: str = Field(min_length=1, max_length=100)
|
||||
target_attributes: dict[str, object] = Field(default_factory=dict)
|
||||
minute_of_day: int = Field(ge=0, le=1439)
|
||||
weekday: int = Field(ge=0, le=6)
|
||||
context_states: dict[str, str] = Field(default_factory=dict)
|
||||
trigger_entity_id: str | None = None
|
||||
trigger_from_state: str | None = None
|
||||
trigger_to_state: str | None = None
|
||||
trigger_delay_seconds: int | None = Field(default=None, ge=0)
|
||||
source: str = Field(default="observed", max_length=40)
|
||||
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||
observed_at: datetime
|
||||
@@ -136,6 +138,7 @@ class BehaviorPattern(BaseModel):
|
||||
|
||||
class BehaviorPrediction(BaseModel):
|
||||
target_state: str
|
||||
target_attributes: dict[str, object] = Field(default_factory=dict)
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
generated_at: datetime
|
||||
reason: str
|
||||
|
||||
Reference in New Issue
Block a user