Updated apps
This commit is contained in:
@@ -10,6 +10,15 @@ shopping_list):
|
||||
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
|
||||
@@ -31,6 +40,10 @@ 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")
|
||||
|
||||
@@ -72,6 +85,75 @@ _SPEECH: dict[str, dict[str, str]] = {
|
||||
"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.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -154,10 +236,43 @@ def _describe(task: dict[str, Any], language: str | None) -> str:
|
||||
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):
|
||||
@@ -208,26 +323,12 @@ class CompleteTaskIntent(intent.IntentHandler):
|
||||
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()
|
||||
|
||||
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
|
||||
@@ -254,3 +355,276 @@ class CompleteTaskIntent(intent.IntentHandler):
|
||||
_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
|
||||
|
||||
Reference in New Issue
Block a user