Coverage for custom_components/supernotify/llm.py: 97%

286 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-08 17:42 +0000

1"""LLM tools for Assist conversation agents and Home Assistant's MCP server - beta. 

2 

3Home Assistant's `llm` integration merges the tools from every integration's `llm` platform into 

4its built-in Assist API, which is also what the MCP server offers by default. Nothing here is 

5offered until switched on in the Supernotify options, where action tools (send, snooze) and 

6diagnostic tools (recent notifications, dry run, snoozes, documentation) are switched on separately. 

7 

8The tools deliberately leave out Supernotify's more open-ended fields - `custom_target` (any 

9e-mail address, phone number etc), `actions`, and media URLs - so an agent can only reach the 

10recipients, deliveries and scenarios already configured. 

11""" 

12 

13from __future__ import annotations 

14 

15import datetime as dt 

16import logging 

17import re 

18from dataclasses import dataclass 

19from typing import TYPE_CHECKING, Any, override 

20 

21import aiohttp 

22from homeassistant.components.llm import LLMTools # type: ignore[unresolved-import,unused-ignore] # HA < 2026.x on py3.13 

23from homeassistant.core import HomeAssistant, callback 

24from homeassistant.helpers.aiohttp_client import async_get_clientsession 

25from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput 

26from homeassistant.util import dt as dt_util 

27from homeassistant.util.hass_dict import HassKey 

28 

29from . import DOMAIN 

30from .archive import summarize_notification 

31from .compat import vol 

32from .const import ( 

33 ATTR_DELIVERY, 

34 ATTR_DRY_RUN, 

35 ATTR_PRIORITY, 

36 ATTR_SCENARIOS_APPLY, 

37 CONF_LLM_ACTION_TOOLS, 

38 CONF_LLM_DIAGNOSTIC_TOOLS, 

39 CONF_LLM_TOOLS, 

40 DRY_RUN_SIMULATE, 

41 PRIORITY_VALUES, 

42) 

43from .model import CommandType, GlobalTargetType, QualifiedTargetType, RecipientType, TargetType 

44from .snoozer import SNOOZE_SCOPES, snooze_name_error 

45 

46if TYPE_CHECKING: 

47 from homeassistant.util.json import JsonObjectType 

48 

49 from .engine import SupernotifyEngine 

50 from .people import Recipient 

51 

52_LOGGER = logging.getLogger(__name__) 

53 

54EVERYONE = "everyone" 

55MAX_HOURS = 7 * 24 

56MAX_NOTIFICATIONS = 50 

57 

58# The documentation site publishes every page as one markdown file for LLMs, plus an index with links 

59DOCS_SITE = "https://supernotify.rhizomatics.org.uk/" 

60DOCS_URL = f"{DOCS_SITE}llms-full.txt" 

61DOCS_INDEX_URL = f"{DOCS_SITE}llms.txt" 

62DOCS_CACHE_TIME = dt.timedelta(days=1) 

63# the line under each page title giving its link, from docgen/llms_preprocess.py 

64DOCS_SOURCE_PREFIX = "Source: " 

65DOCS_SECTIONS_RETURNED = 3 

66DOCS_SECTION_MAX_CHARS = 4000 

67DOCS_STOP_WORDS = frozenset([ 

68 "a", 

69 "an", 

70 "and", 

71 "are", 

72 "can", 

73 "do", 

74 "does", 

75 "for", 

76 "from", 

77 "how", 

78 "i", 

79 "in", 

80 "is", 

81 "it", 

82 "my", 

83 "of", 

84 "on", 

85 "or", 

86 "the", 

87 "to", 

88 "use", 

89 "using", 

90 "what", 

91 "when", 

92 "where", 

93 "which", 

94 "who", 

95 "why", 

96 "with", 

97 # words in almost every page of these docs 

98 "notification", 

99 "notifications", 

100 "notify", 

101 "supernotify", 

102 "send", 

103]) 

104 

105 

106@dataclass 

107class DocsPage: 

108 title: str 

109 url: str | None 

110 text: str 

111 

112 

113@dataclass 

114class _DocsCache: 

115 fetched: dt.datetime 

116 pages: list[DocsPage] 

117 

118 

119DOCS_CACHE: HassKey[_DocsCache] = HassKey(f"{DOMAIN}_llm_docs") 

120 

121SNOOZE_ACTIONS: dict[str, CommandType | None] = { 

122 "snooze": CommandType.SNOOZE, 

123 "silence": CommandType.SILENCE, 

124 "unsnooze": CommandType.NORMAL, 

125 "clear_all": None, 

126} 

127 

128 

129@callback 

130def async_get_tools(hass: HomeAssistant, llm_context: LLMContext, api_id: str) -> LLMTools | None: 

131 """Return the Supernotify tools switched on in the options, rebuilt each time so the 

132 choices offered for deliveries, scenarios and recipients are always the current ones.""" 

133 if api_id != LLM_API_ASSIST: 

134 return None 

135 entries = hass.config_entries.async_loaded_entries(DOMAIN) 

136 if not entries: 

137 return None 

138 engine: SupernotifyEngine = entries[0].runtime_data 

139 options: dict[str, Any] = entries[0].options.get(CONF_LLM_TOOLS, {}) 

140 tools: list[Tool] = [] 

141 if options.get(CONF_LLM_ACTION_TOOLS): 

142 tools.extend([NotifyTool(engine), SnoozeTool(engine)]) 

143 if options.get(CONF_LLM_DIAGNOSTIC_TOOLS): 

144 tools.extend([RecentNotificationsTool(engine), DryRunTool(engine), SnoozesTool(engine), HelpTool(engine)]) 

145 if not tools: 

146 return None 

147 return LLMTools(tools=tools, prompt=_prompt(engine)) 

148 

149 

150def _prompt(engine: SupernotifyEngine) -> str: 

151 context = engine.context 

152 lines = ["Supernotify sends notifications to the household by phone, e-mail, speakers and other ways."] 

153 if deliveries := [_labelled(name, d.alias) for name, d in context.delivery_registry.choosable_deliveries.items()]: 

154 lines.append(f"Deliveries: {', '.join(deliveries)}.") 

155 if scenarios := [_labelled(name, s.alias) for name, s in context.scenario_registry.scenarios.items()]: 

156 lines.append(f"Scenarios: {', '.join(scenarios)}.") 

157 if recipients := _recipient_names(engine): 

158 lines.append(f"Recipients: {', '.join(recipients)}.") 

159 return "\n".join(lines) 

160 

161 

162def _labelled(name: str, alias: str | None) -> str: 

163 return f"{name} ({alias})" if alias and alias != name else name 

164 

165 

166def _display_name(recipient: Recipient) -> str: 

167 return recipient.alias or recipient.name 

168 

169 

170def _recipient_names(engine: SupernotifyEngine) -> list[str]: 

171 return sorted(_display_name(r) for r in engine.context.people_registry.enabled_recipients()) 

172 

173 

174def _person_id(engine: SupernotifyEngine, name: str) -> str | None: 

175 return engine.context.people_registry.person_id_for_name(name) 

176 

177 

178def _notification_fields(engine: SupernotifyEngine, message_required: bool) -> dict[vol.Marker, Any]: 

179 """The notification fields an agent may use - never custom_target, actions or media URLs""" 

180 context = engine.context 

181 fields: dict[vol.Marker, Any] = { 

182 (vol.Required if message_required else vol.Optional)("message", description="The notification text"): str, 

183 vol.Optional("title", description="A short title, used by deliveries that show one"): str, 

184 vol.Optional("priority", description="How urgent the notification is, which decides which deliveries are used"): vol.In( 

185 list(PRIORITY_VALUES) 

186 ), 

187 } 

188 if recipients := _recipient_names(engine): 

189 fields[vol.Optional("recipients", description="Who to notify. Leave out to use the usual recipients")] = [ 

190 vol.In(recipients) 

191 ] 

192 if deliveries := list(context.delivery_registry.choosable_deliveries): 

193 fields[vol.Optional("deliveries", description="Use only these deliveries. Leave out to choose automatically")] = [ 

194 vol.In(deliveries) 

195 ] 

196 if scenarios := list(context.scenario_registry.scenarios): 

197 fields[vol.Optional("scenarios", description="Scenarios to apply, as if their conditions were met")] = [ 

198 vol.In(scenarios) 

199 ] 

200 return fields 

201 

202 

203def _notification_call(engine: SupernotifyEngine, args: dict[str, Any]) -> tuple[list[str] | None, dict[str, Any], list[str]]: 

204 """Turn tool arguments into a target list and action data, plus any recipient names not known""" 

205 person_ids: list[str] = [] 

206 unknown: list[str] = [] 

207 for name in args.get("recipients", []): 

208 if person_id := _person_id(engine, name): 

209 person_ids.append(person_id) 

210 else: 

211 unknown.append(name) 

212 data: dict[str, Any] = {} 

213 if "priority" in args: 

214 data[ATTR_PRIORITY] = args["priority"] 

215 if args.get("deliveries"): 

216 data[ATTR_DELIVERY] = args["deliveries"] 

217 if args.get("scenarios"): 

218 data[ATTR_SCENARIOS_APPLY] = args["scenarios"] 

219 return person_ids or None, data, unknown 

220 

221 

222class SupernotifyTool(Tool): 

223 def __init__(self, engine: SupernotifyEngine) -> None: 

224 self.engine = engine 

225 

226 

227class NotifyTool(SupernotifyTool): 

228 name = "supernotify__notify" 

229 description = ( 

230 "Send a notification to people in the household, e.g. 'tell everyone dinner is ready' or " 

231 "'send Alice an urgent message'. Supernotify chooses how to reach each person from the priority, " 

232 "the active scenarios and who is home, unless deliveries are given." 

233 ) 

234 

235 def __init__(self, engine: SupernotifyEngine) -> None: 

236 super().__init__(engine) 

237 self.parameters = vol.Schema(_notification_fields(engine, message_required=True)) 

238 

239 @override 

240 async def async_call(self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext) -> JsonObjectType: 

241 args = self.parameters(tool_input.tool_args) 

242 target, data, unknown = _notification_call(self.engine, args) 

243 if unknown: 

244 return {"success": False, "error": f"Unknown recipients: {', '.join(unknown)}"} 

245 notification = await self.engine.async_send_message( 

246 args["message"], title=args.get("title"), target=target, data=data, context=llm_context.context 

247 ) 

248 if notification is None: 

249 return {"success": False, "error": "The notification could not be created"} 

250 return { 

251 "success": notification.delivered > 0, 

252 "result": summarize_notification(self.engine, notification.contents()), 

253 } 

254 

255 

256class SnoozeTool(SupernotifyTool): 

257 name = "supernotify__snooze" 

258 description = ( 

259 "Snooze or silence notifications, or turn them back on, e.g. 'mute the doorbell camera alerts for an hour' " 

260 "or 'stop non-urgent notifications until I say'. 'snooze' lasts for the given minutes, 'silence' until " 

261 "undone, 'unsnooze' undoes one snooze, and 'clear_all' removes every snooze for everyone." 

262 ) 

263 

264 def __init__(self, engine: SupernotifyEngine) -> None: 

265 super().__init__(engine) 

266 self.parameters = vol.Schema({ 

267 vol.Required("action"): vol.In(list(SNOOZE_ACTIONS)), 

268 vol.Optional( 

269 "scope", 

270 default="noncritical", 

271 description="What kind of notifications to snooze. 'everything' holds back critical alerts too, " 

272 "so use it only when that is asked for", 

273 ): vol.In(list(SNOOZE_SCOPES)), 

274 vol.Optional( 

275 "name", 

276 description="The delivery, transport, priority or camera entity_id to snooze, when scope is one of " 

277 "those, or for 'tag' a scenario name, or an entity_id, name or alias of the entity a notification is about", 

278 ): str, 

279 vol.Optional( 

280 "recipient", 

281 description="Whose notifications to snooze. Leave out for the person asking, or everyone if unknown", 

282 ): vol.In([*_recipient_names(engine), EVERYONE]), 

283 vol.Optional("minutes", description="How long to snooze for"): vol.All( 

284 vol.Coerce(int), vol.Range(min=1, max=MAX_HOURS * 60) 

285 ), 

286 }) 

287 

288 @override 

289 async def async_call(self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext) -> JsonObjectType: 

290 args = self.parameters(tool_input.tool_args) 

291 snoozer = self.engine.context.snoozer 

292 cmd: CommandType | None = SNOOZE_ACTIONS[args["action"]] 

293 if cmd is None: 

294 return {"success": True, "result": {"cleared": self.engine.clear_snoozes()}} 

295 

296 scope: TargetType = SNOOZE_SCOPES[args["scope"]] 

297 name: str | None = args.get("name") 

298 if isinstance(scope, QualifiedTargetType): 

299 if error := await self._check_name(scope, name, cmd): 

300 return {"success": False, "error": error} 

301 else: 

302 name = None 

303 

304 recipient: str | None = None 

305 recipient_name: str | None = args.get("recipient") 

306 if recipient_name is None: 

307 recipient = self._requesting_person(llm_context) 

308 elif recipient_name != EVERYONE: 

309 recipient = _person_id(self.engine, recipient_name) 

310 recipient_type = RecipientType.USER if recipient else RecipientType.EVERYONE 

311 

312 minutes: int | None = args.get("minutes") 

313 snooze_for = dt.timedelta(minutes=minutes) if minutes else snoozer.snooze_period 

314 snoozer.register_snooze(cmd, scope, name, recipient_type, recipient, snooze_for, reason="Assistant") 

315 if cmd == CommandType.NORMAL and isinstance(scope, GlobalTargetType): 

316 # turning notifications back on undoes a snooze of everything as well as one of noncritical 

317 for other in GlobalTargetType: 

318 if other != scope: 

319 snoozer.register_snooze(cmd, other, None, recipient_type, recipient, None) 

320 snoozes: dict[str, Any] = {"snoozes": self.engine.enquire_snoozes()} 

321 return {"success": True, "result": snoozes} 

322 

323 async def _check_name(self, scope: QualifiedTargetType, name: str | None, cmd: CommandType) -> str | None: 

324 """The same message, in the Home Assistant language, that supernotify.snooze refuses the name with""" 

325 if problem := snooze_name_error(self.engine, scope, name, cmd): 

326 strings: dict[str, str] = await self.engine.context.hass_api.translations("exceptions") 

327 return strings[f"component.{DOMAIN}.exceptions.{problem[0]}.message"].format(**problem[1]) 

328 return None 

329 

330 def _requesting_person(self, llm_context: LLMContext) -> str | None: 

331 user_id: str | None = llm_context.context.user_id if llm_context.context else None 

332 return self.engine.context.people_registry.person_id_for_user_id(user_id) 

333 

334 

335class RecentNotificationsTool(SupernotifyTool): 

336 name = "supernotify__recent_notifications" 

337 description = ( 

338 "List recent notifications and what happened to each one: which deliveries sent, were skipped or failed, " 

339 "and why. Use this for questions like 'why didn't I get the doorbell alert?' or 'what notifications went " 

340 "out today?'. 'occupancy' shows who was home at the time, which decides some deliveries." 

341 ) 

342 

343 def __init__(self, engine: SupernotifyEngine) -> None: 

344 super().__init__(engine) 

345 fields: dict[vol.Marker, Any] = { 

346 vol.Optional("hours", default=24, description="How many hours back to look"): vol.All( 

347 vol.Coerce(int), vol.Range(min=1, max=MAX_HOURS) 

348 ), 

349 vol.Optional("limit", default=10, description="The most notifications to return, newest first"): vol.All( 

350 vol.Coerce(int), vol.Range(min=1, max=MAX_NOTIFICATIONS) 

351 ), 

352 } 

353 if recipients := _recipient_names(engine): 

354 fields[vol.Optional("recipient", description="Only notifications that reached this person")] = vol.In(recipients) 

355 self.parameters = vol.Schema(fields) 

356 

357 @override 

358 async def async_call(self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext) -> JsonObjectType: 

359 args = self.parameters(tool_input.tool_args) 

360 since: dt.datetime = dt_util.now() - dt.timedelta(hours=args["hours"]) 

361 archive = self.engine.context.archive 

362 result: dict[str, Any] = {} 

363 if archive.archive_directory and archive.archive_directory.enabled: 

364 archived = await archive.archive_directory.list_entries(limit=MAX_NOTIFICATIONS, after=since) 

365 else: 

366 last = self.engine.last_notification 

367 archived = [last.contents()] if last and last.created >= since else [] 

368 result["note"] = ( 

369 "The notification archive is off, so only the latest notification since Home Assistant started " 

370 "is known. It can be turned on in the Supernotify options." 

371 ) 

372 summaries = [summarize_notification(self.engine, contents) for contents in archived] 

373 if recipient := args.get("recipient"): 

374 summaries = [s for s in summaries if any(recipient in d.get("recipients", []) for d in s["deliveries"].values())] 

375 result["notifications"] = summaries[: args["limit"]] 

376 return {"success": True, "result": result} 

377 

378 

379class DryRunTool(SupernotifyTool): 

380 name = "supernotify__dry_run" 

381 description = ( 

382 "Work out who a notification would reach right now, and by which deliveries, without sending it. " 

383 "Use this for questions like 'if the alarm goes off, who gets told and how?'. 'occupancy' shows who is " 

384 "home, which decides some deliveries." 

385 ) 

386 

387 def __init__(self, engine: SupernotifyEngine) -> None: 

388 super().__init__(engine) 

389 self.parameters = vol.Schema(_notification_fields(engine, message_required=False)) 

390 

391 @override 

392 async def async_call(self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext) -> JsonObjectType: 

393 args = self.parameters(tool_input.tool_args) 

394 target, data, unknown = _notification_call(self.engine, args) 

395 if unknown: 

396 return {"success": False, "error": f"Unknown recipients: {', '.join(unknown)}"} 

397 data[ATTR_DRY_RUN] = DRY_RUN_SIMULATE 

398 notification = await self.engine.async_send_message( 

399 args.get("message", ""), title=args.get("title"), target=target, data=data, context=llm_context.context 

400 ) 

401 if notification is None: 

402 return {"success": False, "error": "The notification could not be created"} 

403 return {"success": True, "result": summarize_notification(self.engine, notification.contents())} 

404 

405 

406class SnoozesTool(SupernotifyTool): 

407 name = "supernotify__snoozes" 

408 description = "List the notification snoozes and silences in place, and who they apply to." 

409 

410 @override 

411 async def async_call(self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext) -> JsonObjectType: 

412 snoozes: dict[str, Any] = {"snoozes": self.engine.enquire_snoozes()} 

413 return {"success": True, "result": snoozes} 

414 

415 

416def split_docs(text: str, index: str = "") -> list[DocsPage]: 

417 """Split the combined documentation into pages, each starting at a top level heading with a 

418 blank line either side - which a comment in a code example rarely has. Each page's link is its 

419 own "Source:" line, published since v2.10.0, else taken from the index where the titles match""" 

420 urls: dict[str, str] = { 

421 title.casefold(): url.removesuffix("index.md") 

422 for title, url in re.findall(r"^- \[([^\]]+)\]\(([^)]+)\)", index, flags=re.MULTILINE) 

423 } 

424 pages: list[DocsPage] = [] 

425 title: str | None = None 

426 lines: list[str] = text.splitlines() 

427 body: list[str] = [] 

428 

429 def url_for(page_title: str) -> str | None: 

430 # recipe pages are titled "Recipe - X", where the index often has a longer "X ..." 

431 wanted = page_title.casefold().removeprefix("recipe - ") 

432 return urls.get(wanted) or next((u for t, u in urls.items() if t.startswith(wanted)), None) 

433 

434 def finish() -> None: 

435 if title and (page_text := "\n".join(body).strip()): 

436 first_line, _, rest = page_text.partition("\n") 

437 if first_line.startswith(DOCS_SOURCE_PREFIX): 

438 pages.append(DocsPage(title, first_line.removeprefix(DOCS_SOURCE_PREFIX).strip(), rest.strip())) 

439 else: 

440 pages.append(DocsPage(title, url_for(title), page_text)) 

441 

442 for i, line in enumerate(lines): 

443 if line.startswith("# ") and (i == 0 or not lines[i - 1].strip()) and (i + 1 == len(lines) or not lines[i + 1].strip()): 

444 finish() 

445 title, body = line[2:].strip(), [] 

446 else: 

447 body.append(line) 

448 finish() 

449 return pages 

450 

451 

452def search_docs(pages: list[DocsPage], question: str) -> list[DocsPage]: 

453 """The pages that best match the question - most of all in the title, then by how many of its 

454 words they contain, then by how densely, so a long page doesn't win just by being long. 

455 Words are matched by their stem, so 'snooze' finds 'Snoozing'.""" 

456 stems = { 

457 w[: max(4, len(w) - 3)] 

458 for w in re.findall(r"[a-z0-9_]+", question.casefold()) 

459 if len(w) > 2 and w not in DOCS_STOP_WORDS 

460 } 

461 scored: list[tuple[float, int, DocsPage]] = [] 

462 for position, page in enumerate(pages): 

463 title, text = page.title.casefold(), page.text.casefold() 

464 score = 0.0 

465 for stem in stems: 

466 if count := text.count(stem): 

467 score += 2 + min(count * 1000 / len(text), 3) 

468 if stem in title: 

469 score += 6 

470 if score: 

471 scored.append((-score, position, page)) 

472 return [page for _score, _position, page in sorted(scored)[:DOCS_SECTIONS_RETURNED]] 

473 

474 

475async def _docs(hass: HomeAssistant) -> list[DocsPage]: 

476 """The documentation pages, fetched on first use and then at most once a day""" 

477 cached: _DocsCache | None = hass.data.get(DOCS_CACHE) 

478 if cached and dt_util.utcnow() - cached.fetched < DOCS_CACHE_TIME: 

479 return cached.pages 

480 session = async_get_clientsession(hass) 

481 timeout = aiohttp.ClientTimeout(total=15) 

482 async with session.get(DOCS_URL, timeout=timeout) as response: 

483 response.raise_for_status() 

484 text = await response.text() 

485 index = "" 

486 try: 

487 async with session.get(DOCS_INDEX_URL, timeout=timeout) as response: 

488 response.raise_for_status() 

489 index = await response.text() 

490 except (aiohttp.ClientError, TimeoutError) as e: 

491 _LOGGER.debug("SUPERNOTIFY Documentation index not fetched, pages will have no links: %s", e) 

492 pages = split_docs(text, index) 

493 hass.data[DOCS_CACHE] = _DocsCache(dt_util.utcnow(), pages) 

494 return pages 

495 

496 

497class HelpTool(SupernotifyTool): 

498 name = "supernotify__help" 

499 description = ( 

500 "Look up the Supernotify documentation, for how-to questions such as 'how do I e-mail a camera snapshot?' " 

501 "or 'what does inclusion fallback mean?'. Returns the best matching pages, with configuration examples " 

502 "and recipes, to answer from. The documentation is for the latest release, in English." 

503 ) 

504 

505 def __init__(self, engine: SupernotifyEngine) -> None: 

506 super().__init__(engine) 

507 self.parameters = vol.Schema({ 

508 vol.Required("question", description="What to look up, in a few words or a whole question"): str 

509 }) 

510 

511 @override 

512 async def async_call(self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext) -> JsonObjectType: 

513 args = self.parameters(tool_input.tool_args) 

514 try: 

515 pages = await _docs(hass) 

516 except (aiohttp.ClientError, TimeoutError) as e: 

517 _LOGGER.warning("SUPERNOTIFY Unable to fetch documentation from %s: %s", DOCS_URL, e) 

518 return {"success": False, "error": f"The documentation site could not be reached ({e})"} 

519 found: dict[str, Any] = { 

520 "site": DOCS_SITE, 

521 "pages": [ 

522 {"title": page.title, "url": page.url, "text": page.text[:DOCS_SECTION_MAX_CHARS]} 

523 for page in search_docs(pages, args["question"]) 

524 ], 

525 } 

526 if not found["pages"]: 

527 found["note"] = "Nothing matched, try other words" 

528 return {"success": True, "result": found}