Files
HomeAssistantVS/custom_components/maintenance_supporter/intent.py
T

633 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.
* ``MaintenanceSupporterTaskInstructions`` — *"how do I descale the coffee
machine?"* — answers STRICTLY from what is stored on the task (notes,
checklist, linked documents incl. per-task page hints, required spare parts,
documentation link). When nothing is stored it says so and asks whether the
user wants general, non-verified advice — grounded by design, never invented.
* ``MaintenanceSupporterTaskDue`` — *"when is the oil change due?"*
* ``MaintenanceSupporterSnoozeTask`` — *"snooze the oil change"* — suppresses
the task's reminders for the configured snooze duration.
* ``MaintenanceSupporterPartStock`` — *"how many water filters do we have?"*
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"
INTENT_TASK_INSTRUCTIONS = "MaintenanceSupporterTaskInstructions"
INTENT_TASK_DUE = "MaintenanceSupporterTaskDue"
INTENT_SNOOZE_TASK = "MaintenanceSupporterSnoozeTask"
INTENT_PART_STOCK = "MaintenanceSupporterPartStock"
_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}"},
# task due (single-task query)
"due_date_suffix": {
"en": " The next due date is {date}.",
"de": " Der nächste Fälligkeitstermin ist der {date}.",
},
# grounded task guidance
"guide_header": {
"en": "Stored guidance for '{task}' on {object}: {segments}.",
"de": "Hinterlegte Informationen zu '{task}' an {object}: {segments}.",
},
"guide_none": {
"en": (
"There are no stored instructions, documents or spare parts for "
"'{task}' on {object}. I can offer general, non-verified advice "
"instead — would you like that?"
),
"de": (
"Zu '{task}' an {object} sind keine Anleitungen, Dokumente oder "
"Ersatzteile hinterlegt. Ich kann stattdessen allgemeine, "
"ungeprüfte Hinweise geben — möchtest du das?"
),
},
"guide_notes": {"en": "notes: {notes}", "de": "Notizen: {notes}"},
"guide_checklist": {
"en": "{count} checklist steps: {steps}",
"de": "{count} Checklisten-Schritte: {steps}",
},
"guide_doc": {"en": "linked document '{title}'", "de": "verknüpftes Dokument '{title}'"},
"guide_doc_page": {
"en": "linked document '{title}', page {page}",
"de": "verknüpftes Dokument '{title}', Seite {page}",
},
"guide_url": {
"en": "a documentation link is on file",
"de": "ein Dokumentations-Link ist hinterlegt",
},
"guide_part": {
"en": "{qty} × {part} needed{extras}",
"de": "{qty} × {part} benötigt{extras}",
},
"guide_part_loc": {"en": "stored at {loc}", "de": "Lagerort {loc}"},
"guide_part_stock": {"en": "{stock} in stock", "de": "{stock} auf Lager"},
# snooze
"snoozed": {
"en": "Snoozed reminders for '{task}' on {object} for {hours} hours.",
"de": "Erinnerungen für '{task}' an {object} für {hours} Stunden stummgeschaltet.",
},
"snooze_unavailable": {
"en": "Notifications aren't configured, so there is nothing to snooze.",
"de": "Benachrichtigungen sind nicht eingerichtet — es gibt nichts stummzuschalten.",
},
# part stock
"stock_line": {
"en": "{stock} × {part} in stock{loc}{low}.",
"de": "{stock} × {part} auf Lager{loc}{low}.",
},
"stock_loc": {"en": " (stored at {loc})", "de": " (Lagerort: {loc})"},
"stock_low": {
"en": " — at or below the reorder threshold",
"de": " — an oder unter der Nachbestellgrenze",
},
"stock_untracked": {
"en": "Stock isn't tracked for {part}.",
"de": "Für {part} wird kein Bestand geführt.",
},
"part_not_found": {
"en": "I couldn't find a spare part matching '{name}'.",
"de": "Ich habe kein Ersatzteil zu '{name}' gefunden.",
},
}
def _sp(key: str, language: str | None, **fmt: Any) -> str:
# Per-request language (intent_obj.language, e.g. "de-DE") → table key,
# same normalization rule as helpers/i18n (which is hass-bound).
from .helpers.i18n import normalize_language_code
lang = normalize_language_code(language)
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})"
def _resolve_single(
intent_obj: intent.Intent, name: str, snapshot: list[dict[str, Any]]
) -> tuple[dict[str, Any] | None, intent.IntentResponse | None]:
"""Match a spoken name to exactly ONE snapshot row.
Returns ``(row, None)`` on success, ``(None, error_response)`` otherwise —
the shared not-found / ambiguity contract of every name-taking intent.
"""
lang = intent_obj.language
response = intent_obj.create_response()
matches = _match_tasks(name, snapshot)
if not matches:
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("not_found", lang, name=name),
)
return None, 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 None, response
return matches[0], None
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Register the Maintenance Supporter intents."""
intent.async_register(hass, ListTasksIntent())
intent.async_register(hass, CompleteTaskIntent())
intent.async_register(hass, TaskInstructionsIntent())
intent.async_register(hass, TaskDueIntent())
intent.async_register(hass, SnoozeTaskIntent())
intent.async_register(hass, PartStockIntent())
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
target, err = _resolve_single(intent_obj, name, _task_snapshot(hass))
if err is not None:
return err
assert target is not None
response = intent_obj.create_response()
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
class TaskInstructionsIntent(intent.IntentHandler):
"""Speak the STORED guidance for a task — grounded by design (roadmap)."""
intent_type = INTENT_TASK_INSTRUCTIONS
description = (
"Returns the guidance STORED on a home-maintenance task: notes, checklist "
"steps, linked manuals/documents (with a page hint), required spare parts "
"(with storage location and stock) and whether a documentation link is on "
"file. Use for 'how do I …' questions about maintenance tasks. IMPORTANT: "
"this returns only verified, user-stored information — if it reports that "
"nothing is stored, tell the user so and ask whether they want general "
"advice before providing any; never present invented steps as the stored "
"procedure."
)
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
target, err = _resolve_single(intent_obj, name, _task_snapshot(hass))
if err is not None:
return err
assert target is not None
response = intent_obj.create_response()
entry = hass.config_entries.async_get_entry(target["entry_id"])
rd = getattr(entry, "runtime_data", None) if entry else None
# Static fields (notes/checklist/url/consumes_parts) come from the entry
# record — the system of record; the coordinator payload carries only a
# computed subset (consumes_parts, for one, is not in it).
task: dict[str, Any] = {}
if entry is not None:
task = entry.data.get(CONF_TASKS, {}).get(target["task_id"], {}) or {}
segments: list[str] = []
notes = task.get("notes")
if isinstance(notes, str) and notes.strip():
trimmed = notes.strip()
if len(trimmed) > 240:
trimmed = trimmed[:237] + "…"
segments.append(_sp("guide_notes", lang, notes=trimmed))
checklist = [s for s in (task.get("checklist") or []) if isinstance(s, str) and s.strip()]
if checklist:
segments.append(
_sp("guide_checklist", lang, count=len(checklist), steps="; ".join(checklist[:8]))
)
# Documents linked to THIS task, with the per-task page hint when set.
from . import DOCUMENT_STORE_KEY
from .const import CONF_PARTS
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
if doc_store is not None and entry is not None:
object_id = entry.data.get(CONF_OBJECT, {}).get("id", "")
for doc in doc_store.for_object(object_id):
if target["task_id"] not in (doc.get("task_ids") or []):
continue
title = doc.get("title") or doc.get("filename") or doc.get("url") or "document"
page = (doc.get("task_pages") or {}).get(target["task_id"])
if page:
segments.append(_sp("guide_doc_page", lang, title=title, page=page))
else:
segments.append(_sp("guide_doc", lang, title=title))
if isinstance(task.get("documentation_url"), str) and task["documentation_url"].strip():
segments.append(_sp("guide_url", lang))
# Required spare parts with storage location + live stock.
parts = (entry.data.get(CONF_PARTS) or {}) if entry is not None else {}
store = getattr(rd, "store", None) if rd else None
for link in task.get("consumes_parts") or []:
part = parts.get(link.get("part_id")) if isinstance(link, dict) else None
if not isinstance(part, dict):
continue
extras: list[str] = []
if part.get("storage_location"):
extras.append(_sp("guide_part_loc", lang, loc=part["storage_location"]))
stock = store.get_part_stock(link["part_id"]) if store is not None else None
if stock is not None:
extras.append(_sp("guide_part_stock", lang, stock=stock))
segments.append(
_sp(
"guide_part",
lang,
qty=link.get("quantity", 1),
part=part.get("name") or "part",
extras=f" ({', '.join(extras)})" if extras else "",
)
)
if not segments:
# Grounded contract: nothing stored → say so and ASK before any
# general advice — the LLM relays the question instead of inventing.
response.async_set_speech(
_sp("guide_none", lang, task=target["name"], object=target["object_name"])
)
return response
response.async_set_speech(
_sp(
"guide_header",
lang,
task=target["name"],
object=target["object_name"],
segments="; ".join(segments),
)
)
return response
class TaskDueIntent(intent.IntentHandler):
"""Answer when a single task is due."""
intent_type = INTENT_TASK_DUE
description = (
"Tells when a single home-maintenance task is due, matched by its name "
"(the object/appliance name may be included). Use for questions like "
"'when is the oil change due?'"
)
slot_schema = {vol.Required("name"): cv.string}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
slots = self.async_validate_slots(intent_obj.slots)
name = slots["name"]["value"].strip()
lang = intent_obj.language
target, err = _resolve_single(intent_obj, name, _task_snapshot(intent_obj.hass))
if err is not None:
return err
assert target is not None
response = intent_obj.create_response()
speech = _describe(target, lang) + "."
next_due = target.get("next_due")
if isinstance(next_due, str) and next_due:
speech += _sp("due_date_suffix", lang, date=next_due.split("T")[0])
response.async_set_speech(speech)
return response
class SnoozeTaskIntent(intent.IntentHandler):
"""Snooze a task's reminders for the configured snooze duration."""
intent_type = INTENT_SNOOZE_TASK
description = (
"Snoozes (mutes) the reminder notifications of a home-maintenance task "
"for the configured snooze duration. Does NOT change the task's schedule "
"or complete it."
)
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
target, err = _resolve_single(intent_obj, name, _task_snapshot(hass))
if err is not None:
return err
assert target is not None
response = intent_obj.create_response()
from . import NOTIFICATION_MANAGER_KEY
from .const import CONF_SNOOZE_DURATION_HOURS, DEFAULT_SNOOZE_DURATION_HOURS
from .helpers.global_options import get_global_options
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
if nm is None:
response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
_sp("snooze_unavailable", lang),
)
return response
nm.snooze_task(target["entry_id"], target["task_id"])
hours = get_global_options(hass).get(CONF_SNOOZE_DURATION_HOURS, DEFAULT_SNOOZE_DURATION_HOURS)
if isinstance(hours, float) and hours.is_integer():
hours = int(hours) # "4 hours", not "4.0 hours"
response.async_set_speech(
_sp("snoozed", lang, task=target["name"], object=target["object_name"], hours=hours)
)
return response
def _part_snapshot(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Every spare part across all objects, in the _match_tasks row shape
(``name`` + ``object_name``) so the same fuzzy matcher applies."""
from .const import CONF_PARTS
rows: list[dict[str, Any]] = []
for ce in hass.config_entries.async_entries(DOMAIN):
if ce.unique_id == GLOBAL_UNIQUE_ID:
continue
object_name = ce.data.get(CONF_OBJECT, {}).get("name", ce.title)
rd = getattr(ce, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
for part_id, part in (ce.data.get(CONF_PARTS) or {}).items():
if not isinstance(part, dict):
continue
rows.append(
{
"name": str(part.get("name") or ""),
"object_name": object_name,
"storage_location": part.get("storage_location"),
"reorder_threshold": part.get("reorder_threshold"),
"stock": store.get_part_stock(part_id) if store is not None else None,
}
)
return rows
class PartStockIntent(intent.IntentHandler):
"""Answer how many of a spare part are in stock."""
intent_type = INTENT_PART_STOCK
description = (
"Tells how many of a spare part / consumable are in stock (with the "
"storage location), matched by the part's name. Use for questions like "
"'how many water filters do we have left?'"
)
slot_schema = {vol.Required("name"): cv.string}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
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, _part_snapshot(intent_obj.hass))
if not matches:
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("part_not_found", lang, name=name),
)
return response
if len(matches) > 1:
candidates = ", ".join(
_sp("item_on", lang, task=p["name"], object=p["object_name"]) for p in matches[:4]
)
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("ambiguous", lang, candidates=candidates),
)
return response
part = matches[0]
if part["stock"] is None:
response.async_set_speech(_sp("stock_untracked", lang, part=part["name"]))
return response
loc = _sp("stock_loc", lang, loc=part["storage_location"]) if part.get("storage_location") else ""
threshold = part.get("reorder_threshold")
low = (
_sp("stock_low", lang)
if isinstance(threshold, int) and part["stock"] <= threshold
else ""
)
response.async_set_speech(
_sp("stock_line", lang, stock=part["stock"], part=part["name"], loc=loc, low=low)
)
return response