Files
HomeAssistantVS/custom_components/maintenance_supporter/intent.py
T
2026-07-14 23:57:03 -04:00

257 lines
10 KiB
Python

"""Assist intents — query + complete maintenance tasks by voice / LLM agents.
Registered through HA's integration intent platform (``async_setup_intents``,
picked up automatically when the ``intent`` component loads — same mechanism as
shopping_list):
* ``MaintenanceSupporterListTasks`` — *"what maintenance is due?"* Speaks a
snapshot of actionable tasks, optionally filtered by status.
* ``MaintenanceSupporterCompleteTask`` — *"complete the oil change"* — fuzzy-
matches a task by its spoken name (the object name counts too, so "oil change
on the car" works) and records a REAL completion through the coordinator —
history, rotation, part consumption and on-complete actions all fire.
LLM-based Assist pipelines expose every registered intent handler as a tool
automatically (``helpers/llm``), in any language — no setup needed. The classic
sentence-matching agent additionally needs the copy-paste sentence files shipped
under ``assist/custom_sentences/`` (see FEATURES → Voice & Assist).
"""
from __future__ import annotations
import re
from typing import Any
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import intent
from .const import CONF_OBJECT, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID
INTENT_LIST_TASKS = "MaintenanceSupporterListTasks"
INTENT_COMPLETE_TASK = "MaintenanceSupporterCompleteTask"
_ACTIONABLE = ("due_soon", "overdue", "triggered")
# Spoken responses. The LLM path re-phrases in the user's language anyway; the
# classic sentence agent speaks these verbatim, so the languages we ship
# sentence files for (en, de) are fully localised here — others fall back to en.
_SPEECH: dict[str, dict[str, str]] = {
"none_due": {
"en": "Everything is OK — no maintenance needs attention.",
"de": "Alles in Ordnung — keine Wartung fällig.",
},
"tasks_due": {
"en": "{count} maintenance tasks need attention: {items}.",
"de": "{count} Wartungsaufgaben brauchen Aufmerksamkeit: {items}.",
},
"task_due_one": {
"en": "One maintenance task needs attention: {items}.",
"de": "Eine Wartungsaufgabe braucht Aufmerksamkeit: {items}.",
},
"completed": {
"en": "Completed '{task}' on {object}.",
"de": "'{task}' an {object} als erledigt eingetragen.",
},
"not_found": {
"en": "I couldn't find a maintenance task matching '{name}'.",
"de": "Ich habe keine Wartungsaufgabe zu '{name}' gefunden.",
},
"ambiguous": {
"en": "That matches several tasks: {candidates}. Please be more specific.",
"de": "Das passt auf mehrere Aufgaben: {candidates}. Bitte formuliere genauer.",
},
"too_early": {
"en": "'{task}' can only be completed closer to its due date.",
"de": "'{task}' kann erst näher am Fälligkeitstermin erledigt werden.",
},
# status descriptors for list items
"st_overdue": {"en": "{days} days overdue", "de": "seit {days} Tagen überfällig"},
"st_due_today": {"en": "due today", "de": "heute fällig"},
"st_due_in": {"en": "due in {days} days", "de": "fällig in {days} Tagen"},
"st_triggered": {"en": "triggered", "de": "ausgelöst"},
"item_on": {"en": "{task} on {object}", "de": "{task} an {object}"},
}
def _sp(key: str, language: str | None, **fmt: Any) -> str:
# Per-request language (intent_obj.language, e.g. "de-DE") → bare 2-letter
# key, same normalization rule as helpers/i18n (which is hass-bound).
lang = (language or "en")[:2].lower()
table = _SPEECH[key]
return table.get(lang, table["en"]).format(**fmt)
def _task_snapshot(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Live snapshot of every active (non-archived) task across all objects.
Same shape/source as the ``list_tasks`` service: the coordinator's computed
payload, so status/next_due reflect the Store, not stale entry data.
"""
tasks: list[dict[str, Any]] = []
for ce in hass.config_entries.async_entries(DOMAIN):
if ce.unique_id == GLOBAL_UNIQUE_ID:
continue
rd = getattr(ce, "runtime_data", None)
coordinator = getattr(rd, "coordinator", None) if rd else None
if coordinator is None or not coordinator.data:
continue
object_name = ce.data.get(CONF_OBJECT, {}).get("name", ce.title)
for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
status = str(task.get("_status", ""))
if status == "archived":
continue
tasks.append(
{
"entry_id": ce.entry_id,
"task_id": task_id,
"object_name": object_name,
"name": str(task.get("name") or ""),
"status": status,
"days_until_due": task.get("_days_until_due"),
"next_due": task.get("_next_due"),
}
)
return tasks
def _match_tasks(query: str, snapshot: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Fuzzy-match a spoken name against tasks (object name counts too).
An exact task-name match wins outright; otherwise every ≥2-char query token
must appear in "task name + object name" (case-insensitive substring).
"""
q = query.strip().lower()
if not q:
return []
exact = [t for t in snapshot if t["name"].lower() == q]
if len(exact) == 1:
return exact
tokens = [tok for tok in re.split(r"[^a-z0-9äöüß]+", q) if len(tok) >= 2]
if not tokens:
return exact
matches = []
for t in snapshot:
hay = f"{t['name']} {t['object_name']}".lower()
if all(tok in hay for tok in tokens):
matches.append(t)
return exact or matches
def _describe(task: dict[str, Any], language: str | None) -> str:
days = task.get("days_until_due")
if task["status"] == "triggered":
desc = _sp("st_triggered", language)
elif isinstance(days, int) and days < 0:
desc = _sp("st_overdue", language, days=-days)
elif days == 0:
desc = _sp("st_due_today", language)
elif isinstance(days, int):
desc = _sp("st_due_in", language, days=days)
else:
desc = task["status"]
return f"{_sp('item_on', language, task=task['name'], object=task['object_name'])} ({desc})"
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Register the Maintenance Supporter intents."""
intent.async_register(hass, ListTasksIntent())
intent.async_register(hass, CompleteTaskIntent())
class ListTasksIntent(intent.IntentHandler):
"""Speak which maintenance tasks need attention (optionally by status)."""
intent_type = INTENT_LIST_TASKS
description = (
"Lists the user's home-maintenance tasks that need attention "
"(overdue, due soon or sensor-triggered), or filtered by a status. "
"Use for questions like 'what maintenance is due?'"
)
slot_schema = {vol.Optional("status"): vol.In(["ok", "due_soon", "overdue", "triggered"])}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
slots = self.async_validate_slots(intent_obj.slots)
wanted = slots.get("status", {}).get("value")
statuses = (wanted,) if wanted else _ACTIONABLE
tasks = [t for t in _task_snapshot(intent_obj.hass) if t["status"] in statuses]
# Most urgent first: overdue (most days) → due today → due soon.
tasks.sort(key=lambda t: (t["days_until_due"] is None, t["days_until_due"] or 0))
response = intent_obj.create_response()
lang = intent_obj.language
if not tasks:
response.async_set_speech(_sp("none_due", lang))
return response
items = ", ".join(_describe(t, lang) for t in tasks[:8])
key = "task_due_one" if len(tasks) == 1 else "tasks_due"
response.async_set_speech(_sp(key, lang, count=len(tasks), items=items))
return response
class CompleteTaskIntent(intent.IntentHandler):
"""Complete a maintenance task by its spoken name."""
intent_type = INTENT_COMPLETE_TASK
description = (
"Marks a home-maintenance task as completed, matched by its name "
"(the object/appliance name may be included, e.g. 'oil change on the car'). "
"Records a real completion including history."
)
slot_schema = {vol.Required("name"): cv.string}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
name = slots["name"]["value"].strip()
lang = intent_obj.language
response = intent_obj.create_response()
matches = _match_tasks(name, _task_snapshot(hass))
if not matches:
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("not_found", lang, name=name),
)
return response
if len(matches) > 1:
candidates = ", ".join(
_sp("item_on", lang, task=t["name"], object=t["object_name"]) for t in matches[:4]
)
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("ambiguous", lang, candidates=candidates),
)
return response
target = matches[0]
entry = hass.config_entries.async_get_entry(target["entry_id"])
rd = getattr(entry, "runtime_data", None)
coordinator = getattr(rd, "coordinator", None) if rd else None
if coordinator is None:
response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
_sp("not_found", lang, name=name),
)
return response
# Honour the completion window (earliest_completion_days) like the WS
# and To-do paths do — voice must not bypass the contract.
from .websocket.tasks_actions import _completion_blocked
if _completion_blocked(rd, target["task_id"]):
response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
_sp("too_early", lang, task=target["name"]),
)
return response
await coordinator.complete_maintenance(task_id=target["task_id"], completed_by="assist")
response.async_set_speech(
_sp("completed", lang, task=target["name"], object=target["object_name"])
)
return response