diff --git a/script-agent/.gitignore b/script-agent/.gitignore index af753d4..dce693a 100644 --- a/script-agent/.gitignore +++ b/script-agent/.gitignore @@ -1,8 +1,5 @@ __pycache__/ /local/ - -# Local dev scripts (contain a Home Assistant token / host addresses). -run.sh -sync.sh -sync-green.sh +/*.sh +.python-version diff --git a/script-agent/CHANGELOG.md b/script-agent/CHANGELOG.md index c6dc197..54dadb4 100644 --- a/script-agent/CHANGELOG.md +++ b/script-agent/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.1.0 + +- Add a "Create calendar event" blueprint +- Restrict entity fields written with the `domain` shorthand + (`entity: {domain: calendar}`) to that domain, instead of offering every + exposed entity +- Give the model the current date with each command, so a date or date & time + field gets a date instead of "Saturday" +- Edit the system and user prompts on the Settings page +- Reword the system prompt so the no-match reply is asked for in the requested + language, rather than bundled into one sentence with what to say. This rebuilds + the prompt cache once on upgrade + ## 1.0.0 - Initial release diff --git a/script-agent/DOCS.md b/script-agent/DOCS.md index 01a7d15..def4a0e 100644 --- a/script-agent/DOCS.md +++ b/script-agent/DOCS.md @@ -127,11 +127,20 @@ The following field [selectors][] are supported: - Boolean - Color temperature - Date + - The current date is given to the model with each command, so "Saturday" + and "tomorrow" become real dates - Date & time + - As above; the model is told to answer with `YYYY-MM-DDTHH:MM:SS` - Duration + - The model fills this in as `HH:MM:SS`, where Home Assistant's own UI gives + a mapping of `days`/`hours`/`minutes`/`seconds`. Write templates that + accept both — see the `create_calendar_event` [blueprint][blueprints] + - Beware that `as_timedelta` reads a two-part `01:00` as *one minute*, not + one hour - Entity - Uses all [exposed][expose] entity names - - Add a [domain filter][] to restrict possible entities + - Add a [domain filter][] to restrict possible entities. Both the current + `filter` form and the older `domain` shorthand work - Floor - Uses all available floor names and [aliases][] - Number @@ -230,6 +239,35 @@ effect immediately, and grows an automatically sized context when necessary. The page also shows the effective model, context size, CPU threads, and flash attention setting. +### Prompts + +The same page edits the two prompts, also saved in `/data/overrides.yaml`: + +- The **system prompt** comes before the tools and is the cached prefix, so + changing it rebuilds the prompt cache — the same wait as changing which + scripts are targeted. +- The **user prompt** wraps each command and is built fresh every time, so + anything that changes belongs here rather than in the system prompt. It may + use these placeholders, of which `{text}` is required: + + - `{text}` — the sentence to recognize + - `{language}` — the requested response language + - `{date}` — the current date, as `YYYY-MM-DD` + - `{time}` — the current time, as `HH:MM` + - `{datetime}` — the current date and time, ISO 8601 + - `{weekday}` — the current day of the week + +The default user prompt carries the date, which is how a date or date & time +field gets an actual date out of "for an hour at 2pm on Saturday". Recognized +sentences are cached against the finished prompt, so including `{time}` or +`{datetime}` makes [tool call caching](#tool-call-caching) nearly useless: the +prompt then changes every minute. A longer user prompt also costs time on every +command, since it is evaluated per utterance rather than cached. + +**Restore defaults** puts both prompts back. Prompts that match the defaults are +not recorded, so the app keeps following the default if a later version improves +it. + ### Choosing which scripts are targeted Each script has a checkbox. Exposing a script to voice in Home Assistant still diff --git a/script-agent/blueprints/create_calendar_event.yaml b/script-agent/blueprints/create_calendar_event.yaml new file mode 100644 index 0000000..df16bcb --- /dev/null +++ b/script-agent/blueprints/create_calendar_event.yaml @@ -0,0 +1,69 @@ +--- +blueprint: + name: Create calendar event + description: Add an event to a calendar at a date and time for some length of time + domain: script + +mode: parallel + +fields: + calendar: + name: Calendar + description: Name of the calendar to add the event to + required: true + selector: + entity: + filter: + domain: calendar + + start: + name: Start + description: Date and time the event starts + required: true + selector: + datetime: + + duration: + name: Duration + description: How long the event lasts + required: true + selector: + duration: + + summary: + name: Summary + description: Title of the event + required: true + selector: + text: + +sequence: + - variables: + event_start: "{{ as_datetime(start).isoformat() }}" + # A duration selector holds a mapping in Home Assistant's UI, but the model + # fills the field in as an "HH:MM:SS" string, so accept both. + event_end: >- + {%- if duration is mapping -%} + {%- set length = timedelta( + days=duration.get('days', 0) | int(0), + hours=duration.get('hours', 0) | int(0), + minutes=duration.get('minutes', 0) | int(0), + seconds=duration.get('seconds', 0) | int(0) + ) -%} + {%- else -%} + {#- Home Assistant reads "01:00" as MM:SS, but a model that leaves the + seconds off means one hour, so fill them in. -#} + {%- set text = duration | string -%} + {%- set length = as_timedelta( + (text ~ ':00') if text.count(':') == 1 else text + ) -%} + {%- endif -%} + {{ (as_datetime(start) + length).isoformat() }} + + - action: calendar.create_event + target: + entity_id: "{{ calendar }}" + data: + summary: "{{ summary }}" + start_date_time: "{{ event_start }}" + end_date_time: "{{ event_end }}" diff --git a/script-agent/config.yaml b/script-agent/config.yaml index 940d451..c502af7 100644 --- a/script-agent/config.yaml +++ b/script-agent/config.yaml @@ -1,5 +1,5 @@ --- -version: 1.0.0 +version: 1.1.0 slug: script-agent name: Script Agent description: Run Home Assistant scripts by voice with a local language model diff --git a/script-agent/mypy.ini b/script-agent/mypy.ini index 76d6cc2..8cf9815 100644 --- a/script-agent/mypy.ini +++ b/script-agent/mypy.ini @@ -1,4 +1,9 @@ [mypy] +# src is fully annotated. Without this, an unannotated function is silently +# skipped instead of checked, and only shows up as an "annotation-unchecked" +# note the moment it happens to declare a local. +disallow_untyped_defs = True +disallow_incomplete_defs = True [mypy-wyoming.*] ignore_missing_imports = True diff --git a/script-agent/requirements.dev.txt b/script-agent/requirements.dev.txt new file mode 100644 index 0000000..b4d752d --- /dev/null +++ b/script-agent/requirements.dev.txt @@ -0,0 +1,6 @@ +black +flake8 +pylint +isort +mypy +pip-tools diff --git a/script-agent/requirements.txt b/script-agent/requirements.txt index 7f34ed8..fec625a 100644 --- a/script-agent/requirements.txt +++ b/script-agent/requirements.txt @@ -108,7 +108,7 @@ typing-extensions==4.15.0 # llama-cpp-python werkzeug==3.1.8 # via flask -wyoming==1.9.0 +wyoming==1.10.2 # via -r requirements.in yarl==1.24.2 # via aiohttp diff --git a/script-agent/script/update-deps b/script-agent/script/update-deps new file mode 100755 index 0000000..9d94993 --- /dev/null +++ b/script-agent/script/update-deps @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Recompile the pinned requirements from the .in files. + +The order is the reason this script exists: requirements.in reads +requirements.llama.txt, so llama has to be compiled first or the main file is +pinned against a stale copy of it. + +Arguments are passed to pip-compile, so `script/update-deps --upgrade` bumps +every pin and `script/update-deps --upgrade-package flask` bumps just one. +Bumping llama-cpp-python is expensive twice over: it is compiled from source +when the app is installed, and it is part of the model state hash, so every +user pays a prompt cache rebuild on their next boot. Say so in the changelog. +""" + +import subprocess +import sys +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" + +# Compiled in this order; the first is an input to the second. +_REQUIREMENTS = ["requirements.llama.in", "requirements.in"] + +if _VENV_DIR.exists(): + context = venv.EnvBuilder().ensure_directories(_VENV_DIR) + python_exe = context.env_exe +else: + python_exe = "python3" + +for requirements_in in _REQUIREMENTS: + subprocess.check_call( + [python_exe, "-m", "piptools", "compile", requirements_in] + sys.argv[1:], + cwd=_PROGRAM_DIR, + ) diff --git a/script-agent/src/app.py b/script-agent/src/app.py index 210132a..2ff44f4 100644 --- a/script-agent/src/app.py +++ b/script-agent/src/app.py @@ -13,7 +13,13 @@ import overrides from const import BASE_DIR, AppState -from gemma4_recognizer import DEFAULT_MAX_TOKENS, Gemma4Recognizer +from gemma4_recognizer import ( + DEFAULT_MAX_TOKENS, + DEFAULT_SYSTEM_PROMPT, + DEFAULT_USER_PROMPT, + Gemma4Recognizer, + validate_prompts, +) from hass_api import HomeAssistant from intent_server import ScriptAgentEventHandler from web_server import make_web_server, run_web_server @@ -145,6 +151,26 @@ async def main() -> None: "the web UI. Agent will not function." ) + # Prompts edited in the web UI replace the defaults. They are validated here + # rather than trusted: a prompt saved by an older version, or hand-edited in + # the overrides file, must not leave the app unable to recognize anything. + system_prompt = DEFAULT_SYSTEM_PROMPT + user_prompt = DEFAULT_USER_PROMPT + if (all_overrides.system_prompt is not None) or ( + all_overrides.user_prompt is not None + ): + try: + system_prompt, user_prompt = validate_prompts( + all_overrides.system_prompt or system_prompt, + all_overrides.user_prompt or user_prompt, + ) + except ValueError as err: + _LOGGER.warning("Using the default prompts: %s", err) + all_overrides.system_prompt = None + all_overrides.user_prompt = None + system_prompt = DEFAULT_SYSTEM_PROMPT + user_prompt = DEFAULT_USER_PROMPT + _LOGGER.info( "Loading Gemma 4 (repo=%s, filename=%s)", args.hf_repo, args.hf_filename ) @@ -152,6 +178,8 @@ async def main() -> None: repo_id=args.hf_repo.strip(), filename=args.hf_filename.strip(), state_path=args.llama_state, + system_prompt=system_prompt, + user_prompt=user_prompt, cache_size=args.tool_call_cache_size, n_ctx=args.n_ctx if args.n_ctx > 0 else None, n_ctx_overhead=args.n_ctx_overhead, @@ -202,7 +230,7 @@ async def main() -> None: # Handle graceful termination stop_event = asyncio.Event() - def request_stop(): + def request_stop() -> None: stop_event.set() for sig in (signal.SIGTERM, signal.SIGINT): diff --git a/script-agent/src/const.py b/script-agent/src/const.py index 1ca82cf..ea22b7f 100644 --- a/script-agent/src/const.py +++ b/script-agent/src/const.py @@ -12,7 +12,7 @@ BASE_DIR = Path(__file__).parent APP_NAME = "Script Agent" APP_SLUG = "script-agent" -APP_VERSION = "1.8.0" +APP_VERSION = "1.1.0" if TYPE_CHECKING: from gemma4_recognizer import Gemma4Recognizer diff --git a/script-agent/src/gemma4_recognizer.py b/script-agent/src/gemma4_recognizer.py index f835bba..7f4876b 100644 --- a/script-agent/src/gemma4_recognizer.py +++ b/script-agent/src/gemma4_recognizer.py @@ -8,6 +8,7 @@ import re import time from ast import literal_eval +from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union, cast @@ -29,11 +30,33 @@ DEFAULT_REPO = "ggml-org/gemma-4-E2B-it-GGUF" DEFAULT_FILENAME = "gemma-4-E2B-it-Q8_0.gguf" -DEFAULT_SYSTEM_PROMPT = """ -Call tools for the following sentence. -If no tools are called, say you don't understand in the following language. -""" -DEFAULT_USER_PROMPT = 'Sentence: "{text}"\nLanguage: "{language}"' +# Both defaults are written already normalized (see ``normalize_prompt``), so +# restoring them in the web UI cannot leave the saved and running prompts one +# rebuild apart. +DEFAULT_SYSTEM_PROMPT = ( + "Call tools for the sentence below.\n" + "If no tools are called, reply in the language below that you don't understand." +) +DEFAULT_USER_PROMPT = ( + 'Sentence: "{text}"\nLanguage: "{language}"\nToday is {weekday}, {date}' +) + +# The most a prompt may be, so an edit cannot quietly swallow the context. +MAX_PROMPT_CHARS = 2000 + +# What ``{...}`` in the user prompt may refer to. Anything the model needs in +# order to resolve "Saturday" or "tomorrow" belongs here rather than in the +# system prompt: the system prompt is the cached prefix, so a date in it would +# be rebuilt (minutes, on a Raspberry Pi) every time the day changed. +PROMPT_PLACEHOLDERS = { + "text": "the sentence to recognize", + "language": "the requested response language", + "date": "the current date, as YYYY-MM-DD", + "time": "the current time, as HH:MM", + "datetime": "the current date and time, ISO 8601", + "weekday": "the current day of the week", +} +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") _LOGGER = logging.getLogger(__name__) @@ -91,12 +114,13 @@ def __init__( def required_n_ctx(self, tools: List[Dict[str, Any]]) -> int: """Context size needed for the fixed prompt plus one utterance. - Assumes chars / 3 is a conservative upper bound on tokens; the user - prompt and templating are covered by the overhead. Rounded up to a + Assumes chars / 3 is a conservative upper bound on tokens; the sentence + itself and the templating are covered by the overhead. Rounded up to a multiple of 64. """ with io.StringIO() as prompt_file: print(self.system_prompt, file=prompt_file) + print(self.user_prompt, file=prompt_file) json.dump(_sort_tools(tools), prompt_file) num_chars = len(prompt_file.getvalue()) @@ -264,6 +288,78 @@ def reload(self, tools: List[Dict[str, Any]]) -> None: self.ready = True _LOGGER.info("Now using %s tool(s)", len(new_tools)) + def set_prompts(self, system_prompt: str, user_prompt: str) -> None: + """Change the prompts, rebuilding the cached prefix when it is affected. + + The system prompt is the cached prefix, so changing it costs a full + rebuild -- the same wait as changing the tool set. The user prompt is + evaluated per utterance, so changing it is free unless it no longer fits + in the context. + + Must be called on the same single thread that serves recognition. + """ + assert self.llm, "Not loaded" + assert self.tools is not None, "Not loaded" + + system_prompt, user_prompt = validate_prompts(system_prompt, user_prompt) + system_changed = normalize_prompt(self.system_prompt) != system_prompt + user_changed = normalize_prompt(self.user_prompt) != user_prompt + if not (system_changed or user_changed): + return + + old_system_prompt = self.system_prompt + old_user_prompt = self.user_prompt + + def restore_prompts() -> None: + self.system_prompt = old_system_prompt + self.system_message = {"role": "system", "content": old_system_prompt} + self.user_prompt = old_user_prompt + + self.system_prompt = system_prompt + self.system_message = {"role": "system", "content": system_prompt} + self.user_prompt = user_prompt + + needed_n_ctx = self.required_n_ctx(self.tools) + if (self.n_ctx is not None) and (needed_n_ctx > self.llm.n_ctx()): + restore_prompts() + raise ValueError( + f"These prompts need at least {needed_n_ctx} context tokens, but " + f"the fixed context is {self.llm.n_ctx()}" + ) + + grow_context = (self.n_ctx is None) and (needed_n_ctx > self.llm.n_ctx()) + if not (system_changed or grow_context): + # Only the per-utterance prompt moved, and it still fits. + if self.cache is not None: + self.cache.clear() + _LOGGER.info("User prompt updated") + return + + old_llm = self.llm + live_state = old_llm.save_state() + self.ready = False + try: + if grow_context: + _LOGGER.info( + "Growing context from %s to %s token(s) for the new prompts", + self.llm.n_ctx(), + needed_n_ctx, + ) + self._create_llm(needed_n_ctx) + + self._restore_or_build_state() + except Exception: + restore_prompts() + self.llm = old_llm + self.llm.load_state(live_state) + self.ready = True + raise + + if self.cache is not None: + self.cache.clear() + self.ready = True + _LOGGER.info("Prompts updated") + def set_max_tokens(self, max_tokens: int) -> None: """Change the generation limit, growing an automatic context if needed. @@ -321,7 +417,14 @@ def get_tool_calls(self, text: str, language: str = "en") -> MODEL_RESPONSE: assert self.llm, "Not loaded" text = text.strip() - cache_key = f"{language}: {text}" + user_content = format_prompt( + self.user_prompt, prompt_values(text, language, datetime.now()) + ) + + # Keyed on the rendered prompt, not the sentence: a prompt that carries + # the date must not answer tomorrow's "add it for Saturday" with the + # datetime it worked out today. + cache_key = f"{language}: {user_content}" if self.cache is not None: cached_response = self.cache.get(cache_key) if cached_response is not None: @@ -334,12 +437,7 @@ def get_tool_calls(self, text: str, language: str = "en") -> MODEL_RESPONSE: self.llm.create_chat_completion( messages=[ self.system_message, # type: ignore - { - "role": "user", - "content": self.user_prompt.format( - text=text, language=language - ), - }, + {"role": "user", "content": user_content}, ], tools=self.tools, # type: ignore temperature=self.temperature, @@ -390,6 +488,8 @@ def describe(self) -> Dict[str, Any]: "temperature": self.temperature, "flash_attn": self.flash_attn, "draft_model": draft_model, + "system_prompt": self.system_prompt, + "user_prompt": self.user_prompt, } def run_sentences( @@ -447,8 +547,9 @@ def run_sentences( self.system_message, # type: ignore { "role": "user", - "content": self.user_prompt.format( - text=text, language=language + "content": format_prompt( + self.user_prompt, + prompt_values(text, language, datetime.now()), ), }, ], @@ -500,6 +601,82 @@ def run_sentences( # ----------------------------------------------------------------------------- +def normalize_prompt(prompt: str) -> str: + """A prompt with insignificant whitespace removed. + + Prompts are edited in a browser textarea, which brings back line endings and + trailing spaces of its own. Comparing normalized prompts keeps a form + round-trip from counting as a change, which would rebuild the prompt cache. + """ + lines = prompt.replace("\r\n", "\n").replace("\r", "\n").strip().split("\n") + return "\n".join(line.rstrip() for line in lines) + + +def unknown_placeholders(prompt: str) -> List[str]: + """``{...}`` names in a prompt that nothing will ever fill in.""" + return sorted( + { + name + for name in _PLACEHOLDER_RE.findall(prompt) + if name not in PROMPT_PLACEHOLDERS + } + ) + + +def validate_prompts(system_prompt: str, user_prompt: str) -> Tuple[str, str]: + """Return both prompts normalized, or raise ValueError saying what is wrong.""" + system_prompt = normalize_prompt(system_prompt) + user_prompt = normalize_prompt(user_prompt) + + for label, prompt in ( + ("System prompt", system_prompt), + ("User prompt", user_prompt), + ): + if not prompt: + raise ValueError(f"{label} cannot be empty") + if len(prompt) > MAX_PROMPT_CHARS: + raise ValueError( + f"{label} cannot be longer than {MAX_PROMPT_CHARS} characters" + ) + + unknown = unknown_placeholders(prompt) + if unknown: + raise ValueError( + f"{label} uses unknown placeholder(s) " + f"{', '.join('{' + name + '}' for name in unknown)}. Available: " + f"{', '.join('{' + name + '}' for name in sorted(PROMPT_PLACEHOLDERS))}" + ) + + # Without the sentence the model is being asked to recognize nothing. + if "{text}" not in user_prompt: + raise ValueError("User prompt must include {text}") + + return system_prompt, user_prompt + + +def format_prompt(template: str, values: Dict[str, str]) -> str: + """Fill in the ``{...}`` placeholders a prompt is allowed to use. + + Deliberately not ``str.format``: prompts are user-editable, and a stray + brace must not turn every command into a KeyError. + """ + return _PLACEHOLDER_RE.sub( + lambda match: values.get(match.group(1), match.group(0)), template + ) + + +def prompt_values(text: str, language: str, now: datetime) -> Dict[str, str]: + """What the user prompt's placeholders stand for, for one utterance.""" + return { + "text": text, + "language": language, + "date": now.strftime("%Y-%m-%d"), + "time": now.strftime("%H:%M"), + "datetime": now.strftime("%Y-%m-%dT%H:%M:%S"), + "weekday": now.strftime("%A"), + } + + def _parse_tool_calls(text: str) -> List[Tuple[str, Dict[str, Any]]]: text = _normalize_gemma_tool_text(text) calls = [] diff --git a/script-agent/src/hass_api.py b/script-agent/src/hass_api.py index 4e8723c..d1afe2f 100644 --- a/script-agent/src/hass_api.py +++ b/script-agent/src/hass_api.py @@ -3,8 +3,7 @@ import logging from collections import defaultdict from dataclasses import dataclass, field -from datetime import datetime -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any, Callable, Dict, List, Optional, Set, Tuple from urllib.parse import urlparse, urlunparse import aiohttp @@ -87,13 +86,30 @@ def _apply_satellite_registry_info( ) +def _domain_set(domains: Any) -> Optional[Set[str]]: + """One or more domain names as a set, or None when there is no restriction.""" + if not domains: + return None + + if isinstance(domains, str): + return {domains} + + if isinstance(domains, list): + return {domain for domain in domains if isinstance(domain, str)} or None + + return None + + def _get_entity_filter_domains( entity_selector: Dict[str, Any], ) -> Optional[Set[str]]: - """Get domains from either Home Assistant entity filter representation.""" + """Get domains from any Home Assistant entity filter representation.""" entity_filters = entity_selector.get("filter") if not entity_filters: - return None + # The shorthand that predates `filter` (``entity: {domain: calendar}``) + # is still valid, and still what most blueprints are written with. + # Without this, such a field is offered every exposed entity. + return _domain_set(entity_selector.get("domain")) if isinstance(entity_filters, dict): entity_filters = [entity_filters] @@ -102,14 +118,12 @@ def _get_entity_filter_domains( for entity_filter in entity_filters: if not isinstance(entity_filter, dict): return None - filter_domains = entity_filter.get("domain") - if not filter_domains: + filter_domains = _domain_set(entity_filter.get("domain")) + if filter_domains is None: # This alternative does not restrict the domain, so neither can we. return None - if isinstance(filter_domains, str): - domains.add(filter_domains) - else: - domains.update(filter_domains) + + domains.update(filter_domains) return domains or None @@ -400,7 +414,9 @@ def next_id() -> int: return satellite_info - async def _get_blueprint_descriptions(self, websocket, next_id) -> Dict[str, str]: + async def _get_blueprint_descriptions( + self, websocket: aiohttp.ClientWebSocketResponse, next_id: Callable[[], int] + ) -> Dict[str, str]: """Blueprint path -> description, for every script blueprint.""" await websocket.send_json( {"id": next_id(), "type": "blueprint/list", "domain": "script"} @@ -420,7 +436,10 @@ async def _get_blueprint_descriptions(self, websocket, next_id) -> Dict[str, str return descriptions async def _get_script_configs( - self, websocket, next_id, script_ids: Set[str] + self, + websocket: aiohttp.ClientWebSocketResponse, + next_id: Callable[[], int], + script_ids: Set[str], ) -> Dict[str, Dict[str, Any]]: """Get the full configuration exposed by each script entity.""" configs: Dict[str, Dict[str, Any]] = {} @@ -501,7 +520,6 @@ async def get_script_tools( what Home Assistant calls things. """ tools: List[Tool] = [] - now = datetime.now() names = name_overrides or NameOverrides() # Every area and floor, with the names Home Assistant gives it. @@ -724,16 +742,31 @@ def next_id() -> int: field_prop.update(_select_property(selector_config)) elif "date" in selector: field_prop["format"] = "date" - field_description += f"\nDate in YYYY-MM-DD format. The current year is {now.year}" + field_description += ( + "\nDate in YYYY-MM-DD format. Work out the date " + "from the current date, which is given with the " + "sentence." + ) elif "time" in selector: field_prop["format"] = "time" field_description += "\nTime in HH:MM:SS format, or HH:MM if seconds are not needed" elif "datetime" in selector: field_prop["format"] = "date-time" - field_description += f"\nISO 8601 datetime. Include timezone offset when known. The current year is {now.year}" + field_description += ( + "\nISO 8601 datetime in YYYY-MM-DDTHH:MM:SS format. " + "Work out the date from the current date, which is " + "given with the sentence. Never name a weekday or " + "use a word like tomorrow." + ) elif "duration" in selector: field_prop["format"] = "duration" - field_description += "\nDuration in HH:MM:SS format, or HH:MM if seconds are not needed" + # Always all three parts: Home Assistant reads a + # two-part duration as MM:SS, so "01:00" would + # quietly mean one minute rather than one hour. + field_description += ( + "\nDuration in HH:MM:SS format, including the " + "hours even when they are zero" + ) elif "color_rgb" in selector: field_prop["type"] = "array" field_prop["items"] = { diff --git a/script-agent/src/intent_server.py b/script-agent/src/intent_server.py index 6b46d90..ff8e7a4 100644 --- a/script-agent/src/intent_server.py +++ b/script-agent/src/intent_server.py @@ -71,8 +71,8 @@ class ScriptAgentEventHandler(AsyncEventHandler): def __init__( self, state: AppState, - *args, - **kwargs, + *args: Any, + **kwargs: Any, ) -> None: """Initialize event handler.""" super().__init__(*args, **kwargs) diff --git a/script-agent/src/overrides.py b/script-agent/src/overrides.py index 429bbd2..6ea5c0d 100644 --- a/script-agent/src/overrides.py +++ b/script-agent/src/overrides.py @@ -241,14 +241,25 @@ class Overrides: scripts: ScriptOverrides = field(default_factory=ScriptOverrides) names: NameOverrides = field(default_factory=NameOverrides) max_tokens: Optional[int] = None + # Prompts as edited in the web UI. None means "use the startup default", + # which is how a reset is recorded. + system_prompt: Optional[str] = None + user_prompt: Optional[str] = None def as_dict(self) -> Dict[str, object]: data: Dict[str, object] = { **self.scripts.as_dict(), "names": self.names.as_dict(), } + settings: Dict[str, object] = {} if self.max_tokens is not None: - data["settings"] = {"max_tokens": self.max_tokens} + settings["max_tokens"] = self.max_tokens + if self.system_prompt is not None: + settings["system_prompt"] = self.system_prompt + if self.user_prompt is not None: + settings["user_prompt"] = self.user_prompt + if settings: + data["settings"] = settings return data @@ -348,6 +359,7 @@ def load(path: Optional[Path]) -> Overrides: ) settings = data.get("settings") or {} max_tokens: Optional[int] = None + prompts: Dict[str, Optional[str]] = {"system_prompt": None, "user_prompt": None} if isinstance(settings, dict): configured_max_tokens = settings.get("max_tokens") if ( @@ -363,10 +375,27 @@ def load(path: Optional[Path]) -> Overrides: configured_max_tokens, ) + # Only the shape is checked here; what makes a prompt usable is the + # recognizer's business, and the app falls back to the default when it + # rejects one. + for setting_key in prompts: + configured_prompt = settings.get(setting_key) + if isinstance(configured_prompt, str) and configured_prompt.strip(): + prompts[setting_key] = configured_prompt + elif configured_prompt is not None: + _LOGGER.warning( + "Ignoring invalid %s setting in %s: %r", + setting_key, + path, + configured_prompt, + ) + return Overrides( scripts=script_overrides, names=name_overrides, max_tokens=max_tokens, + system_prompt=prompts["system_prompt"], + user_prompt=prompts["user_prompt"], ) diff --git a/script-agent/src/templates/settings.html b/script-agent/src/templates/settings.html index 77abd38..4016a61 100644 --- a/script-agent/src/templates/settings.html +++ b/script-agent/src/templates/settings.html @@ -8,6 +8,22 @@ .settings-form { display: flex; align-items: end; gap: 0.75rem; flex-wrap: wrap; } .settings-form label { display: grid; gap: 0.35rem; font-weight: 600; } .settings-form input { width: 8rem; padding: 0.5rem 0.6rem; border-radius: 7px; font: inherit; } +.prompt-form { display: grid; gap: 1rem; } +.prompt-form label { display: grid; gap: 0.35rem; font-weight: 600; } +.prompt-form textarea { + width: 100%; + padding: 0.6rem; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 7px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; + resize: vertical; +} +.placeholders { margin: 0; padding: 0; list-style: none; font-size: 0.85rem; + color: var(--muted); } +.placeholders li { margin: 0.1rem 0; } {% endblock %} {% block content %} @@ -44,6 +60,55 @@

Maximum tokens

+
+
+
+

Prompts

+

The system prompt is what the model is told before the + tools, and is part of the cached prefix — changing it rebuilds the + prompt cache, which can take several minutes. The user prompt wraps each + command and is evaluated every time, so anything that changes (such as + today's date) belongs there.

+
+
+
+
+ + +
+

The user prompt may use these + placeholders; {text} is required. Including + {time} or {datetime} makes the sentence cache + nearly useless, since the prompt then changes every minute.

+
    + {% for name, meaning in placeholders %} +
  • {{ "{" }}{{ name }}{{ "}" }} — {{ meaning }}
  • + {% endfor %} +
+
+
+ + + + {% if prompts_customized %}Customized in the web UI.{% else %}Using the built-in defaults.{% endif %} + +
+
+ {% if not can_save %} +
Prompts cannot be saved because no overrides path was configured.
+ {% endif %} +
+
+
@@ -103,5 +168,49 @@

Flash attention

settingsSave.disabled = false; } }); + +const promptForm = document.getElementById("prompt-form"); +const promptSave = document.getElementById("prompt-save"); +const promptReset = document.getElementById("prompt-reset"); +const promptStatus = document.getElementById("prompt-status"); +const systemPrompt = document.getElementById("system-prompt"); +const userPrompt = document.getElementById("user-prompt"); +const defaultSystemPrompt = {{ default_system_prompt | tojson }}; +const defaultUserPrompt = {{ default_user_prompt | tojson }}; + +promptReset.addEventListener("click", () => { + systemPrompt.value = defaultSystemPrompt; + userPrompt.value = defaultUserPrompt; + promptStatus.textContent = "Defaults restored. Select Apply to use them."; +}); + +promptForm.addEventListener("submit", async event => { + event.preventDefault(); + promptSave.disabled = true; + promptStatus.textContent = "Applying…"; + beginModelChange("Applying prompts and rebuilding model context…"); + try { + const resp = await fetch("{{ url_for('settings_prompts_apply') }}", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + system_prompt: systemPrompt.value, + user_prompt: userPrompt.value + }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || ("HTTP " + resp.status)); + systemPrompt.value = data.system_prompt; + userPrompt.value = data.user_prompt; + promptStatus.textContent = data.customized + ? "Saved. Context is " + data.n_ctx + " tokens." + : "Saved the built-in defaults. Context is " + data.n_ctx + " tokens."; + } catch (error) { + promptStatus.textContent = error.message; + } finally { + endModelChange(); + promptSave.disabled = false; + } +}); {% endblock %} diff --git a/script-agent/src/util.py b/script-agent/src/util.py index 9aad9b9..ae22ef7 100644 --- a/script-agent/src/util.py +++ b/script-agent/src/util.py @@ -4,18 +4,18 @@ class LRUCache: - def __init__(self, maxsize: int): + def __init__(self, maxsize: int) -> None: self.maxsize = maxsize self.data: OrderedDict[str, Any] = OrderedDict() - def get(self, key: str, default=None): + def get(self, key: str, default: Any = None) -> Any: if key not in self.data: return default self.data.move_to_end(key) return self.data[key] - def set(self, key: str, value: Any): + def set(self, key: str, value: Any) -> None: if key in self.data: self.data.move_to_end(key) diff --git a/script-agent/src/web_server.py b/script-agent/src/web_server.py index 812b4c2..e9f1d2f 100644 --- a/script-agent/src/web_server.py +++ b/script-agent/src/web_server.py @@ -6,19 +6,31 @@ import threading import time from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Union +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Tuple, Union from flask import Flask, Response, jsonify, render_template, request, url_for +from flask.typing import ResponseReturnValue from werkzeug.middleware.proxy_fix import ProxyFix import benchmark import overrides from benchmark import Fixture from const import AppState +from gemma4_recognizer import ( + DEFAULT_SYSTEM_PROMPT, + DEFAULT_USER_PROMPT, + PROMPT_PLACEHOLDERS, + normalize_prompt, + validate_prompts, +) from hass_api import HomeAssistantInfo, SatelliteInfo, Tool from overrides import AREA, ENTITY, FLOOR, NAME_KINDS, NameOverrides, ScriptOverrides from tool_mapping import map_tool_call, required_fields +if TYPE_CHECKING: + # WSGI types live in typeshed only, so they cannot be imported at runtime. + from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment + _LOGGER = logging.getLogger(__name__) MAX_PASSES = 10 @@ -215,11 +227,11 @@ def issue_run_token(script_id: str, variables: Dict[str, Any]) -> str: return token @flask_app.context_processor - def inject_url_for(): + def inject_url_for() -> Dict[str, Any]: return dict(url_for=url_for) # pylint: disable=use-dict-literal @flask_app.route("/", methods=["GET"]) - def index(): + def index() -> ResponseReturnValue: """Show every script, split by whether the model can call it.""" scripts = [ _describe_script( @@ -240,7 +252,7 @@ def index(): ) @flask_app.route("/test", methods=["GET"]) - def test_page(): + def test_page() -> ResponseReturnValue: """Show the interactive sentence tester.""" satellites = sorted( ( @@ -255,7 +267,7 @@ def test_page(): return render_template("test.html", satellites=satellites) @flask_app.route("/settings", methods=["GET"]) - def settings_page(): + def settings_page() -> ResponseReturnValue: """Show runtime model information and editable recognition settings.""" model = state.recognizer.describe() return render_template( @@ -270,10 +282,94 @@ def settings_page(): can_save=state.overrides_path is not None, min_max_tokens=MIN_MAX_TOKENS, max_max_tokens=MAX_MAX_TOKENS, + system_prompt=normalize_prompt(state.recognizer.system_prompt), + user_prompt=normalize_prompt(state.recognizer.user_prompt), + default_system_prompt=normalize_prompt(DEFAULT_SYSTEM_PROMPT), + default_user_prompt=normalize_prompt(DEFAULT_USER_PROMPT), + prompts_customized=(state.overrides.system_prompt is not None) + or (state.overrides.user_prompt is not None), + placeholders=sorted(PROMPT_PLACEHOLDERS.items()), + ) + + @flask_app.route("/settings/prompts", methods=["POST"]) + def settings_prompts_apply() -> ResponseReturnValue: + """Apply and persist the system and user prompts.""" + if state.overrides_path is None: + return jsonify({"error": "No settings file is configured"}), 503 + if not state.recognizer.ready: + return jsonify({"error": "Model is still loading"}), 503 + + body = request.get_json(silent=True) or {} + system_prompt = body.get("system_prompt") + user_prompt = body.get("user_prompt") + if not isinstance(system_prompt, str) or not isinstance(user_prompt, str): + return jsonify({"error": "Both prompts are required"}), 400 + + try: + system_prompt, user_prompt = validate_prompts(system_prompt, user_prompt) + except ValueError as err: + return jsonify({"error": str(err)}), 400 + + if not state.reload_lock.acquire(blocking=False): + return jsonify({"error": "Already applying a change"}), 409 + + old_system_prompt = state.recognizer.system_prompt + old_user_prompt = state.recognizer.user_prompt + candidate_overrides = copy.deepcopy(state.overrides) + # A prompt that matches the default is not recorded, so the app keeps + # following the default if a later version improves it. + candidate_overrides.system_prompt = ( + None + if system_prompt == normalize_prompt(DEFAULT_SYSTEM_PROMPT) + else system_prompt + ) + candidate_overrides.user_prompt = ( + None + if user_prompt == normalize_prompt(DEFAULT_USER_PROMPT) + else user_prompt + ) + + state.model_rebuilding.set() + try: + future = state.llama_executor.submit( + state.recognizer.set_prompts, system_prompt, user_prompt + ) + future.result() + try: + overrides.save(state.overrides_path, candidate_overrides) + except Exception: + # Keep the persisted and effective prompts aligned if writing fails. + rollback = state.llama_executor.submit( + state.recognizer.set_prompts, old_system_prompt, old_user_prompt + ) + rollback.result() + raise + state.overrides = candidate_overrides + except ValueError as err: + return jsonify({"error": str(err)}), 400 + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Failed to apply prompts") + return jsonify({"error": f"Could not apply prompts: {err}"}), 500 + finally: + state.model_rebuilding.clear() + state.reload_lock.release() + + return jsonify( + { + "system_prompt": state.recognizer.system_prompt, + "user_prompt": state.recognizer.user_prompt, + "customized": (candidate_overrides.system_prompt is not None) + or (candidate_overrides.user_prompt is not None), + "n_ctx": ( + state.recognizer.llm.n_ctx() + if state.recognizer.llm is not None + else None + ), + } ) @flask_app.route("/settings", methods=["POST"]) - def settings_apply(): + def settings_apply() -> ResponseReturnValue: """Apply and persist the maximum generation length.""" if state.overrides_path is None: return jsonify({"error": "No settings file is configured"}), 503 @@ -341,7 +437,7 @@ def _fetch_from_hass( """ assert state.loop is not None, "No event loop" - async def fetch(): + async def fetch() -> Tuple[HomeAssistantInfo, List[Tool]]: hass_info = await state.hass.get_home_info() all_tools = await state.hass.get_script_tools( hass_info, candidate_overrides.names @@ -360,7 +456,7 @@ async def fetch(): return hass_info, all_tools @flask_app.route("/reload", methods=["POST"]) - def reload_from_hass(): + def reload_from_hass() -> ResponseReturnValue: """Re-read Home Assistant, then rebuild the tools and model prefix. The app otherwise reads Home Assistant once, at start, so this is how a @@ -401,7 +497,7 @@ def _requested_names(body: Dict[str, Any]) -> Optional[Set[str]]: return {str(name) for name in names} & known @flask_app.route("/overrides/estimate", methods=["POST"]) - def overrides_estimate(): + def overrides_estimate() -> ResponseReturnValue: """Report what a prospective tool set would cost, before applying it.""" body = request.get_json(silent=True) or {} names = _requested_names(body) @@ -423,7 +519,7 @@ def overrides_estimate(): ) @flask_app.route("/overrides", methods=["POST"]) - def overrides_apply(): + def overrides_apply() -> ResponseReturnValue: """Change which scripts are targeted, then rebuild the model prefix. Recognition is unavailable while the prefix is rebuilt, which can take @@ -484,13 +580,13 @@ def overrides_apply(): state.reload_lock.release() @flask_app.route("/tools.json", methods=["GET"]) - def tools_json(): + def tools_json() -> ResponseReturnValue: """The tools as given to the model (OpenAI function spec), for debugging.""" tools = [tool.tool for tool in state.tools.values()] return Response(json.dumps(tools, indent=2), mimetype="application/json") @flask_app.route("/test", methods=["POST"]) - def test(): + def test() -> ResponseReturnValue: """Recognize one sentence and report what it would run, without running it.""" body = request.get_json(silent=True) or {} text = str(body.get("text") or "").strip() @@ -575,7 +671,7 @@ def test(): ) @flask_app.route("/test/run", methods=["POST"]) - def test_run(): + def test_run() -> ResponseReturnValue: """Run one resolved test result exactly once.""" body = request.get_json(silent=True) or {} run_id = body.get("run_id") @@ -613,7 +709,9 @@ def _name_rows() -> Dict[str, List[Dict[str, Any]]]: names = state.overrides.names rows: Dict[str, List[Dict[str, Any]]] = {} - def row(kind: str, target_id: str, default_names: List[str], extra: str = ""): + def row( + kind: str, target_id: str, default_names: List[str], extra: str = "" + ) -> Dict[str, Any]: current = names.names_for(kind, target_id, default_names) return { "id": target_id, @@ -639,7 +737,7 @@ def row(kind: str, target_id: str, default_names: List[str], extra: str = ""): return rows @flask_app.route("/names", methods=["GET"]) - def names_page(): + def names_page() -> ResponseReturnValue: """Edit what the model may call each entity, area, and floor.""" return render_template( "names.html", @@ -660,7 +758,7 @@ def _default_names(kind: str, target_id: str) -> List[str]: return [name for name in target.names if name] if target else [] @flask_app.route("/names/field", methods=["GET"]) - def names_field_page(): + def names_field_page() -> ResponseReturnValue: """Edit names for one script's field only. Reached from that field on the Scripts page, so the list is already the @@ -725,7 +823,7 @@ def names_field_page(): ) @flask_app.route("/names/field", methods=["POST"]) - def names_field_apply(): + def names_field_apply() -> ResponseReturnValue: """Replace one script field's name overrides, then rebuild.""" if not state.recognizer.ready: return jsonify({"error": "Model is still loading"}), 503 @@ -769,7 +867,7 @@ def names_field_apply(): return jsonify({"num_overridden": len(scoped)}) @flask_app.route("/names", methods=["POST"]) - def names_apply(): + def names_apply() -> ResponseReturnValue: """Replace name overrides, then rebuild the tools and model prefix. Names feed the enums, which are built while reading Home Assistant, so @@ -821,22 +919,22 @@ def names_apply(): ) @flask_app.route("/health") - def health(): + def health() -> ResponseReturnValue: # Deliberately "ok" while the model is still loading: the first boot can # spend many minutes downloading it, and the container should not be # restarted for being slow. Use /status to see readiness. return {"status": "ok"}, 200 @flask_app.route("/status") - def status(): + def status() -> ResponseReturnValue: return {"model_loaded": state.recognizer.ready}, 200 @flask_app.route("/benchmark", methods=["GET"]) - def benchmark_page(): + def benchmark_page() -> ResponseReturnValue: return render_template("benchmark.html", max_passes=MAX_PASSES) @flask_app.route("/benchmark/run", methods=["POST"]) - def benchmark_run(): + def benchmark_run() -> ResponseReturnValue: if fixture is None: return {"error": fixture_error or "No benchmark fixture"}, 500 if not state.recognizer.ready: @@ -863,7 +961,7 @@ def benchmark_run(): def run_web_server(flask_app: Flask, host: str, port: int) -> threading.Thread: - def run_flask(): + def run_flask() -> None: logging.getLogger("werkzeug").setLevel(logging.ERROR) flask_app.run(host=host, port=port, use_reloader=False) @@ -875,10 +973,12 @@ def run_flask(): class IngressPrefixMiddleware: """Ingress fix for Home Assistant app web UI.""" - def __init__(self, app): + def __init__(self, app: "WSGIApplication") -> None: self.app = app - def __call__(self, environ, start_response): + def __call__( + self, environ: "WSGIEnvironment", start_response: "StartResponse" + ) -> Iterable[bytes]: ingress_path = environ.get("HTTP_X_INGRESS_PATH", "") if ingress_path: environ["SCRIPT_NAME"] = ingress_path diff --git a/script-agent/tests/test_gemma4_recognizer.py b/script-agent/tests/test_gemma4_recognizer.py index 6d1e130..9175892 100644 --- a/script-agent/tests/test_gemma4_recognizer.py +++ b/script-agent/tests/test_gemma4_recognizer.py @@ -2,6 +2,7 @@ import sys import tempfile import unittest +from datetime import datetime from pathlib import Path from types import ModuleType from unittest.mock import patch @@ -15,10 +16,15 @@ sys.modules["llama_cpp"] = llama_cpp_stub from gemma4_recognizer import ( # noqa: E402 + DEFAULT_SYSTEM_PROMPT, + DEFAULT_USER_PROMPT, LLAMA_CPP_VERSION, Gemma4Recognizer, _get_tools_hash, _parse_tool_calls, + format_prompt, + prompt_values, + validate_prompts, ) @@ -89,6 +95,151 @@ def test_truncated_response_is_not_partially_executed(self): self.assertIn("64-token generation limit", text) +class PromptTests(unittest.TestCase): + def test_default_user_prompt_carries_the_current_date(self): + # Without it the model cannot turn "Saturday" into a date, and answers a + # datetime field with the word instead. + content = format_prompt( + DEFAULT_USER_PROMPT, + prompt_values("book it for saturday", "en", datetime(2026, 8, 31, 9, 5)), + ) + + self.assertIn('Sentence: "book it for saturday"', content) + self.assertIn("Monday", content) + self.assertIn("2026-08-31", content) + + def test_unknown_placeholder_is_left_alone_rather_than_raising(self): + self.assertEqual( + "say {something} about hi", + format_prompt("say {something} about {text}", {"text": "hi"}), + ) + + def test_default_prompts_are_valid_and_already_normalized(self): + # Written normalized so that restoring them in the web UI leaves the + # saved and running prompts identical, rather than one rebuild apart. + system_prompt, user_prompt = validate_prompts( + DEFAULT_SYSTEM_PROMPT, DEFAULT_USER_PROMPT + ) + + self.assertEqual(DEFAULT_SYSTEM_PROMPT, system_prompt) + self.assertEqual(DEFAULT_USER_PROMPT, user_prompt) + + def test_user_prompt_without_the_sentence_is_rejected(self): + with self.assertRaisesRegex(ValueError, r"must include \{text\}"): + validate_prompts(DEFAULT_SYSTEM_PROMPT, "Language: {language}") + + def test_misspelled_placeholder_is_rejected(self): + with self.assertRaisesRegex(ValueError, r"\{weekdya\}"): + validate_prompts(DEFAULT_SYSTEM_PROMPT, "{text} on {weekdya}") + + def test_empty_prompt_is_rejected(self): + with self.assertRaisesRegex(ValueError, "System prompt cannot be empty"): + validate_prompts(" \n ", DEFAULT_USER_PROMPT) + + def test_cache_key_follows_the_date_the_prompt_carried(self): + recognizer = Gemma4Recognizer(state_path="unused.bin", cache_size=10) + recognizer.tools = [] + recognizer.llm = _FakeLlama( + response={"choices": [{"message": {"content": "no tools"}}]} + ) + + with patch("gemma4_recognizer.datetime") as clock: + clock.now.return_value = datetime(2026, 8, 31, 9, 5) + recognizer.get_tool_calls("book it for saturday") + recognizer.get_tool_calls("book it for saturday") + self.assertEqual(1, recognizer.llm.create_calls) + + # Same sentence, next day: the answer would be a day early. + clock.now.return_value = datetime(2026, 9, 1, 9, 5) + recognizer.get_tool_calls("book it for saturday") + + self.assertEqual(2, recognizer.llm.create_calls) + + +class PromptSettingsTests(unittest.TestCase): + def _recognizer(self): + recognizer = Gemma4Recognizer(state_path="unused.bin", cache_size=10) + recognizer.tools = [{"type": "function", "function": {"name": "demo"}}] + recognizer.llm = _FakeLlama(n_ctx=1024) + recognizer.ready = True + return recognizer + + def test_form_round_trip_does_not_rebuild(self): + recognizer = self._recognizer() + recognizer.cache.set("en: cached", ([], "cached")) + + with patch.object(recognizer, "_restore_or_build_state") as restore: + # What a textarea gives back: no leading newline, CRLF line endings. + recognizer.set_prompts( + DEFAULT_SYSTEM_PROMPT.strip().replace("\n", "\r\n"), + DEFAULT_USER_PROMPT, + ) + + restore.assert_not_called() + self.assertEqual(DEFAULT_SYSTEM_PROMPT, recognizer.system_prompt) + self.assertIsNotNone(recognizer.cache.get("en: cached")) + + def test_user_prompt_change_clears_the_cache_without_rebuilding(self): + recognizer = self._recognizer() + recognizer.cache.set("en: cached", ([], "cached")) + + with patch.object(recognizer, "_restore_or_build_state") as restore: + recognizer.set_prompts(DEFAULT_SYSTEM_PROMPT, "{text} ({language})") + + restore.assert_not_called() + self.assertEqual("{text} ({language})", recognizer.user_prompt) + self.assertIsNone(recognizer.cache.get("en: cached")) + self.assertTrue(recognizer.ready) + + def test_system_prompt_change_rebuilds_the_prefix(self): + recognizer = self._recognizer() + + with patch.object(recognizer, "_restore_or_build_state") as restore: + recognizer.set_prompts("Only call one tool.", DEFAULT_USER_PROMPT) + + restore.assert_called_once_with() + self.assertEqual("Only call one tool.", recognizer.system_prompt) + self.assertEqual("Only call one tool.", recognizer.system_message["content"]) + self.assertTrue(recognizer.ready) + + def test_rejected_prompt_leaves_the_running_ones_alone(self): + recognizer = self._recognizer() + + with self.assertRaises(ValueError): + recognizer.set_prompts("Only call one tool.", "no sentence here") + + self.assertEqual(DEFAULT_SYSTEM_PROMPT, recognizer.system_prompt) + self.assertEqual(DEFAULT_USER_PROMPT, recognizer.user_prompt) + + def test_failed_rebuild_rolls_the_prompts_back(self): + recognizer = self._recognizer() + old_llm = recognizer.llm + + with patch.object( + recognizer, + "_restore_or_build_state", + side_effect=RuntimeError("prefix failed"), + ): + with self.assertRaisesRegex(RuntimeError, "prefix failed"): + recognizer.set_prompts("Only call one tool.", DEFAULT_USER_PROMPT) + + self.assertEqual(DEFAULT_SYSTEM_PROMPT, recognizer.system_prompt) + self.assertEqual(DEFAULT_SYSTEM_PROMPT, recognizer.system_message["content"]) + self.assertIs(old_llm, recognizer.llm) + self.assertEqual([{"state": "good"}], old_llm.loaded_states) + self.assertTrue(recognizer.ready) + + def test_fixed_context_rejects_prompts_that_will_not_fit(self): + recognizer = self._recognizer() + recognizer.n_ctx = 1024 + + with patch.object(recognizer, "required_n_ctx", return_value=2048): + with self.assertRaisesRegex(ValueError, "fixed context is 1024"): + recognizer.set_prompts("Only call one tool.", DEFAULT_USER_PROMPT) + + self.assertEqual(DEFAULT_SYSTEM_PROMPT, recognizer.system_prompt) + + class RuntimeSettingsTests(unittest.TestCase): def test_max_tokens_grows_automatic_context(self): recognizer = Gemma4Recognizer( diff --git a/script-agent/tests/test_hass_api.py b/script-agent/tests/test_hass_api.py index 67dabf5..8f86155 100644 --- a/script-agent/tests/test_hass_api.py +++ b/script-agent/tests/test_hass_api.py @@ -1,4 +1,7 @@ import unittest +from pathlib import Path + +import yaml from hass_api import ( HomeAssistant, @@ -91,6 +94,71 @@ def test_unrestricted_filter_alternative_does_not_restrict_domains(self): ) ) + def test_shorthand_domain_restricts_domains(self): + # `entity: {domain: calendar}` predates `filter` and is still valid, so a + # field written that way must not be offered every exposed entity. + self.assertEqual( + {"calendar"}, + _get_entity_filter_domains({"domain": "calendar"}), + ) + + def test_shorthand_domain_list_restricts_domains(self): + self.assertEqual( + {"light", "switch"}, + _get_entity_filter_domains({"domain": ["light", "switch"]}), + ) + + def test_shorthand_without_domain_does_not_restrict_domains(self): + self.assertIsNone(_get_entity_filter_domains({"device_class": "outlet"})) + self.assertIsNone(_get_entity_filter_domains({})) + + def test_filter_beats_shorthand_domain(self): + self.assertEqual( + {"calendar"}, + _get_entity_filter_domains( + {"domain": "light", "filter": {"domain": "calendar"}} + ), + ) + + +class BlueprintTests(unittest.TestCase): + """The bundled examples are what people copy, so keep them parseable.""" + + def _blueprints(self): + blueprint_dir = Path(__file__).parent.parent / "blueprints" + paths = sorted(blueprint_dir.glob("*.yaml")) + self.assertTrue(paths, "No blueprints found") + return [(path, yaml.safe_load(path.read_text("utf-8"))) for path in paths] + + def test_entity_fields_declare_a_domain(self): + for path, blueprint in self._blueprints(): + for field_key, field_info in (blueprint.get("fields") or {}).items(): + selector = field_info.get("selector") or {} + if "entity" not in selector: + continue + + with self.subTest(blueprint=path.name, field=field_key): + self.assertIsNotNone( + _get_entity_filter_domains(selector["entity"] or {}), + f"{field_key} is offered every exposed entity", + ) + + def test_calendar_event_targets_calendars(self): + path = Path(__file__).parent.parent / "blueprints/create_calendar_event.yaml" + blueprint = yaml.safe_load(path.read_text("utf-8")) + fields = blueprint["fields"] + + self.assertEqual( + {"calendar"}, + _get_entity_filter_domains(fields["calendar"]["selector"]["entity"]), + ) + self.assertEqual({"datetime": None}, fields["start"]["selector"]) + self.assertEqual({"duration": None}, fields["duration"]["selector"]) + self.assertTrue( + all(fields[key].get("required") for key in fields), + "Every field is needed to create an event", + ) + class SelectorSchemaTests(unittest.TestCase): def test_labelled_select_options_use_submitted_values(self): diff --git a/script-agent/tests/test_release.py b/script-agent/tests/test_release.py index 28698ef..0f4b214 100644 --- a/script-agent/tests/test_release.py +++ b/script-agent/tests/test_release.py @@ -1,4 +1,5 @@ import argparse +import re import unittest from pathlib import Path from unittest.mock import patch @@ -24,6 +25,16 @@ def test_config_identity_matches_runtime_identity(self): def test_config_version_matches_runtime_version(self): self.assertEqual(APP_VERSION, self.config["version"]) + def test_changelog_documents_the_current_version(self): + # The version is written in three places: config.yaml, const.py, and the + # changelog's newest heading. The first two are checked above; this is + # the copy that is easy to leave behind when the number changes. + changelog = (self.project_dir / "CHANGELOG.md").read_text("utf-8") + versions = re.findall(r"^##\s+(\S+)", changelog, flags=re.MULTILINE) + + self.assertTrue(versions, "CHANGELOG.md has no version headings") + self.assertEqual(APP_VERSION, versions[0]) + def test_flash_attention_is_enabled_by_default(self): self.assertIs(True, self.config["options"]["flash_attention"]) diff --git a/script-agent/tests/test_web_server.py b/script-agent/tests/test_web_server.py index ae5e49c..710621a 100644 --- a/script-agent/tests/test_web_server.py +++ b/script-agent/tests/test_web_server.py @@ -5,6 +5,11 @@ from unittest.mock import Mock, patch from const import AppState +from gemma4_recognizer import ( + DEFAULT_SYSTEM_PROMPT, + DEFAULT_USER_PROMPT, + validate_prompts, +) from hass_api import HomeAssistantInfo, SatelliteInfo, Tool from models import Entity from overrides import AREA, ENTITY, FLOOR, NameOverrides, Overrides, load @@ -33,6 +38,9 @@ def __init__(self, error=None): self.error = error self.reload_calls = [] self.max_token_calls = [] + self.prompt_calls = [] + self.system_prompt = DEFAULT_SYSTEM_PROMPT + self.user_prompt = DEFAULT_USER_PROMPT self.max_tokens = 128 self.n_ctx = None self.ready = True @@ -53,6 +61,15 @@ def set_max_tokens(self, max_tokens): raise self.error self.max_tokens = max_tokens + def set_prompts(self, system_prompt, user_prompt): + self.prompt_calls.append((system_prompt, user_prompt)) + if self.error: + raise self.error + # The real recognizer validates and normalizes before it commits. + self.system_prompt, self.user_prompt = validate_prompts( + system_prompt, user_prompt + ) + def describe(self): return { "repo_id": self.repo_id, @@ -63,6 +80,8 @@ def describe(self): "temperature": 0.0, "flash_attn": self.flash_attn, "draft_model": None, + "system_prompt": self.system_prompt, + "user_prompt": self.user_prompt, } def get_tool_calls(self, _text, _language): @@ -489,6 +508,83 @@ def test_settings_page_rolls_back_when_persistence_fails(self): self.assertEqual(128, self.state.recognizer.max_tokens) self.assertIsNone(self.state.overrides.max_tokens) + def test_settings_page_shows_the_prompts_and_their_placeholders(self): + response = self.client.get("/settings") + + self.assertEqual(200, response.status_code) + self.assertIn(b"Sentence: "{text}"", response.data) + self.assertIn(b"{weekday}", response.data) + self.assertIn(b"Using the built-in defaults.", response.data) + + def test_prompts_are_applied_and_persisted(self): + response = self.client.post( + "/settings/prompts", + json={ + "system_prompt": "Only call one tool.\r\n", + "user_prompt": '"{text}" on {weekday}', + }, + ) + + self.assertEqual(200, response.status_code) + self.assertTrue(response.get_json()["customized"]) + self.assertEqual("Only call one tool.", self.state.recognizer.system_prompt) + self.assertEqual( + "Only call one tool.", load(self.state.overrides_path).system_prompt + ) + self.assertEqual( + '"{text}" on {weekday}', load(self.state.overrides_path).user_prompt + ) + + def test_default_prompts_are_not_recorded_as_overrides(self): + self.state.overrides.system_prompt = "Only call one tool." + + response = self.client.post( + "/settings/prompts", + json={ + "system_prompt": DEFAULT_SYSTEM_PROMPT, + "user_prompt": DEFAULT_USER_PROMPT, + }, + ) + + self.assertEqual(200, response.status_code) + self.assertFalse(response.get_json()["customized"]) + self.assertIsNone(self.state.overrides.system_prompt) + self.assertIsNone(load(self.state.overrides_path).system_prompt) + + def test_prompt_without_the_sentence_is_rejected(self): + response = self.client.post( + "/settings/prompts", + json={ + "system_prompt": DEFAULT_SYSTEM_PROMPT, + "user_prompt": "Language: {language}", + }, + ) + + self.assertEqual(400, response.status_code) + self.assertIn("{text}", response.get_json()["error"]) + self.assertEqual([], self.state.recognizer.prompt_calls) + + def test_prompts_roll_back_when_persistence_fails(self): + with patch("web_server.overrides.save", side_effect=OSError("disk full")): + response = self.client.post( + "/settings/prompts", + json={ + "system_prompt": "Only call one tool.", + "user_prompt": DEFAULT_USER_PROMPT, + }, + ) + + self.assertEqual(500, response.status_code) + self.assertEqual( + [ + ("Only call one tool.", DEFAULT_USER_PROMPT), + (DEFAULT_SYSTEM_PROMPT, DEFAULT_USER_PROMPT), + ], + self.state.recognizer.prompt_calls, + ) + self.assertEqual(DEFAULT_SYSTEM_PROMPT, self.state.recognizer.system_prompt) + self.assertIsNone(self.state.overrides.system_prompt) + def test_field_details_are_available_as_json_for_dialog(self): response = self.client.get( "/names/field?script=notify_mike&field=target&format=json"