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 @@
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.
+{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"