| from __future__ import annotations |
|
|
| import json |
| import re |
| from typing import Any |
|
|
| from pydantic import BaseModel, ValidationError |
|
|
| from .automation import AutomationDocument, automation_schema |
|
|
|
|
| SYSTEM_PROMPT = """You compile home automation requests into Tiny Trigger rules. |
| Return JSON only. Never return code, markdown, explanations, or tool calls. |
| The root object MUST include a non-empty "rules" array. |
| Use video conditions in when: present, count, near, far, moving. |
| Use state gates in gate: enabled, cooldown. |
| Use trigger.on for edge behavior: while, enter, exit, change. |
| Use only these action types: simulate, webhook. |
| Use trigger.on="enter" for state assertions like "must be on", "should be on", "keep on", "turn on when", or "notify when". |
| Use trigger.on="exit" with a plain then action list for requests like "when the person leaves", "when it disappears", or "when it stops meeting the condition". |
| Use trigger.on="while" only when the user explicitly wants repeated actions while a condition remains true, usually with a cooldown. |
| When the request says one object is near, next to, beside, at, by, close to, or in front of another object, you MUST emit a near condition. |
| Do not replace a near relation with two present conditions. |
| Use max_gap_percent for near/far box-edge distance. It is the largest horizontal/vertical edge gap between boxes in normalized frame percent; touching or overlapping boxes have gap 0. |
| Use moving for simple same-object displacement across sampled frames, such as "car moving" or "person walks". Use min_displacement_ratio, default 0.15 (displacement as a fraction of the object's own box size), window_frames, minimum/default 3, and max_missing_frames, default 1. |
| Do not generate speed, direction, long-gap re-identification, or trajectory path rules. |
| If the user mentions elapsed time since an action or limiting repeat fires, encode it as gate.cooldown. |
| If the user asks for one action when a condition starts and another action when it stops, use trigger.on="change" and then.enter / then.exit. |
| """ |
|
|
| DEFAULT_REPLICATE_MODEL = "openai/gpt-5.2" |
| DEFAULT_OPENAI_MODEL = "gpt-5.5" |
| DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6" |
|
|
|
|
| class LLMCompileResult(BaseModel): |
| raw_text: str |
| document: AutomationDocument |
|
|
|
|
| def _invalid_json_error(provider: str, raw_text: str, error: Exception) -> ValueError: |
| preview = raw_text.strip() |
| if len(preview) > 1200: |
| preview = preview[:1200] + "..." |
| return ValueError( |
| f"{provider} returned text that was not valid Tiny Trigger JSON. " |
| f"Validation error: {error}. Raw response: {preview}" |
| ) |
|
|
|
|
| def compile_automation_with_replicate( |
| *, |
| instruction: str, |
| class_names: list[str], |
| api_token: str, |
| model: str = DEFAULT_REPLICATE_MODEL, |
| reasoning_effort: str = "medium", |
| timeout: float = 600.0, |
| ) -> LLMCompileResult: |
| """Compile natural language into validated rules through Replicate.""" |
| api_token = _clean_api_key(api_token, "Replicate") |
| try: |
| import replicate |
| except ImportError as exc: |
| raise RuntimeError("Install replicate to use the Replicate compiler.") from exc |
|
|
| user_prompt = _build_user_prompt(instruction=instruction, class_names=class_names) |
| raw_text = _stream_replicate_completion( |
| model=model, |
| prompt=_provider_prompt(user_prompt), |
| api_token=api_token, |
| reasoning_effort=reasoning_effort, |
| timeout=timeout, |
| replicate_module=replicate, |
| ) |
| try: |
| return _validate_compile_result(raw_text) |
| except (json.JSONDecodeError, ValidationError, ValueError) as exc: |
| raise _invalid_json_error("Replicate", raw_text, exc) from exc |
|
|
|
|
| def compile_automation_with_openai( |
| *, |
| instruction: str, |
| class_names: list[str], |
| api_key: str, |
| model: str = DEFAULT_OPENAI_MODEL, |
| timeout: float = 120.0, |
| ) -> LLMCompileResult: |
| """Compile natural language into validated rules through OpenAI.""" |
| api_key = _clean_api_key(api_key, "OpenAI") |
| user_prompt = _build_user_prompt(instruction=instruction, class_names=class_names) |
| try: |
| import openai |
| except ImportError: |
| try: |
| import requests |
| except ImportError as exc: |
| raise RuntimeError("Install openai or requests to use the OpenAI compiler.") from exc |
| raw_text = _post_openai_chat_completion( |
| endpoint="https://api.openai.com/v1/chat/completions", |
| api_key=api_key, |
| model=model, |
| user_prompt=user_prompt, |
| timeout=timeout, |
| requests_module=requests, |
| ) |
| else: |
| raw_text = _openai_chat_completion( |
| api_key=api_key, |
| model=model, |
| user_prompt=user_prompt, |
| timeout=timeout, |
| openai_module=openai, |
| ) |
| try: |
| return _validate_compile_result(raw_text) |
| except (json.JSONDecodeError, ValidationError, ValueError) as exc: |
| raise _invalid_json_error("OpenAI", raw_text, exc) from exc |
|
|
|
|
| def compile_automation_with_anthropic( |
| *, |
| instruction: str, |
| class_names: list[str], |
| api_key: str, |
| model: str = DEFAULT_ANTHROPIC_MODEL, |
| timeout: float = 120.0, |
| ) -> LLMCompileResult: |
| """Compile natural language into validated rules through Anthropic Claude.""" |
| api_key = _clean_api_key(api_key, "Anthropic") |
| user_prompt = _build_user_prompt(instruction=instruction, class_names=class_names) |
| try: |
| import anthropic |
| except ImportError: |
| try: |
| import requests |
| except ImportError as exc: |
| raise RuntimeError("Install anthropic or requests to use the Anthropic compiler.") from exc |
| raw_text = _post_anthropic_message( |
| endpoint="https://api.anthropic.com/v1/messages", |
| api_key=api_key, |
| model=model, |
| user_prompt=user_prompt, |
| timeout=timeout, |
| requests_module=requests, |
| ) |
| else: |
| raw_text = _anthropic_message( |
| api_key=api_key, |
| model=model, |
| user_prompt=user_prompt, |
| timeout=timeout, |
| anthropic_module=anthropic, |
| ) |
| try: |
| return _validate_compile_result(raw_text) |
| except (json.JSONDecodeError, ValidationError, ValueError) as exc: |
| raise _invalid_json_error("Anthropic", raw_text, exc) from exc |
|
|
|
|
| def _provider_prompt(user_prompt: str) -> str: |
| return f"{SYSTEM_PROMPT}\n\n{user_prompt}\n\nReturn only the JSON object." |
|
|
|
|
| def _stream_replicate_completion( |
| *, |
| model: str, |
| prompt: str, |
| api_token: str, |
| reasoning_effort: str, |
| timeout: float, |
| replicate_module: Any, |
| ) -> str: |
| _split_replicate_model(model) |
| payload = { |
| "prompt": prompt, |
| "messages": [], |
| "verbosity": "medium", |
| "reasoning_effort": reasoning_effort, |
| } |
| client = replicate_module.Client(api_token=api_token) |
| chunks: list[str] = [] |
| try: |
| for event in client.stream(model, input=payload): |
| chunks.append(str(event)) |
| except Exception as exc: |
| raise RuntimeError(_provider_exception_message("Replicate", exc)) from exc |
| text = "".join(chunks).strip() |
| if not text: |
| raise ValueError("Replicate stream returned no output.") |
| return text |
|
|
|
|
| def _split_replicate_model(model: str) -> tuple[str, str]: |
| parts = model.strip().split("/", 1) |
| if len(parts) != 2 or not all(parts): |
| raise ValueError("Replicate model must be in owner/model format, for example openai/gpt-5.2.") |
| return parts[0], parts[1] |
|
|
|
|
| def _post_openai_chat_completion( |
| *, |
| endpoint: str, |
| api_key: str, |
| model: str, |
| user_prompt: str, |
| timeout: float, |
| requests_module: Any, |
| ) -> str: |
| payload = _chat_payload(model=model, user_prompt=user_prompt, response_format="json_object") |
| try: |
| response = requests_module.post( |
| endpoint, |
| headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, |
| json=payload, |
| timeout=timeout, |
| ) |
| response.raise_for_status() |
| except Exception as exc: |
| raise RuntimeError(_provider_exception_message("OpenAI", exc)) from exc |
| body = response.json() |
| return body["choices"][0]["message"]["content"] |
|
|
|
|
| def _openai_chat_completion( |
| *, |
| api_key: str, |
| model: str, |
| user_prompt: str, |
| timeout: float, |
| openai_module: Any, |
| ) -> str: |
| client = openai_module.OpenAI(api_key=api_key, timeout=timeout) |
| try: |
| response = client.chat.completions.create( |
| **_chat_payload(model=model, user_prompt=user_prompt, response_format="json_object") |
| ) |
| except Exception as exc: |
| raise RuntimeError(_provider_exception_message("OpenAI", exc)) from exc |
| content = response.choices[0].message.content |
| if isinstance(content, list): |
| return "".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) |
| return str(content or "") |
|
|
|
|
| def _post_anthropic_message( |
| *, |
| endpoint: str, |
| api_key: str, |
| model: str, |
| user_prompt: str, |
| timeout: float, |
| requests_module: Any, |
| ) -> str: |
| try: |
| response = requests_module.post( |
| endpoint, |
| headers={ |
| "x-api-key": api_key, |
| "anthropic-version": "2023-06-01", |
| "Content-Type": "application/json", |
| }, |
| json={ |
| "model": model, |
| "system": SYSTEM_PROMPT, |
| "messages": [{"role": "user", "content": user_prompt}], |
| "max_tokens": 512, |
| "temperature": 0, |
| }, |
| timeout=timeout, |
| ) |
| response.raise_for_status() |
| except Exception as exc: |
| raise RuntimeError(_provider_exception_message("Anthropic", exc)) from exc |
| body = response.json() |
| chunks = body.get("content") or [] |
| return "".join(str(chunk.get("text", "")) for chunk in chunks if isinstance(chunk, dict)) |
|
|
|
|
| def _anthropic_message( |
| *, |
| api_key: str, |
| model: str, |
| user_prompt: str, |
| timeout: float, |
| anthropic_module: Any, |
| ) -> str: |
| client = anthropic_module.Anthropic(api_key=api_key, timeout=timeout) |
| try: |
| response = client.messages.create( |
| model=model, |
| system=SYSTEM_PROMPT, |
| messages=[{"role": "user", "content": user_prompt}], |
| max_tokens=512, |
| temperature=0, |
| ) |
| except Exception as exc: |
| raise RuntimeError(_provider_exception_message("Anthropic", exc)) from exc |
| chunks = getattr(response, "content", []) or [] |
| texts: list[str] = [] |
| for chunk in chunks: |
| text = getattr(chunk, "text", None) |
| if text is None and isinstance(chunk, dict): |
| text = chunk.get("text") |
| if text: |
| texts.append(str(text)) |
| return "".join(texts) |
|
|
|
|
| def _validate_compile_result(raw_text: str) -> LLMCompileResult: |
| data = json.loads(extract_json_object(raw_text)) |
| document = AutomationDocument.model_validate(data) |
| if not document.rules: |
| raise ValueError("LLM response must include a non-empty rules array.") |
| return LLMCompileResult(raw_text=raw_text, document=document) |
|
|
|
|
| def _clean_api_key(api_key: str | None, provider: str) -> str: |
| cleaned = (api_key or "").strip() |
| if not cleaned: |
| raise ValueError(f"Paste a {provider} API key.") |
| if any(char in cleaned for char in ("\n", "\r", "\t")): |
| raise ValueError(f"{provider} API key must be a single-line token with no whitespace.") |
| if "Traceback" in cleaned or 'File "' in cleaned: |
| raise ValueError( |
| f"{provider} API key field looks like it contains a pasted error log, not an API key." |
| ) |
| return cleaned |
|
|
|
|
| def _provider_exception_message(provider: str, exc: Exception) -> str: |
| parts = [f"{provider} API request failed"] |
| status = getattr(exc, "status", None) or getattr(exc, "status_code", None) |
| if status: |
| parts.append(f"status {status}") |
| detail = getattr(exc, "detail", None) |
| if detail: |
| parts.append(str(detail)) |
| response = getattr(exc, "response", None) |
| if response is not None: |
| response_status = getattr(response, "status_code", None) |
| if response_status and not status: |
| parts.append(f"status {response_status}") |
| try: |
| body = response.json() |
| except Exception: |
| body = getattr(response, "text", "") |
| if body: |
| parts.append(str(body)) |
| if len(parts) == 1: |
| parts.append(str(exc)) |
| message = ". ".join(part for part in parts if part) |
| status_text = str(status or "") |
| if response is not None: |
| response_status = getattr(response, "status_code", None) |
| if response_status: |
| status_text = str(response_status) |
| lower_message = message.lower() |
| if status_text == "429" or "throttled" in lower_message or "rate limit" in lower_message: |
| message += ( |
| ". This is a provider rate limit, separate from account credits. " |
| "Wait a bit or switch provider." |
| ) |
| if status_text == "404" or "not found" in lower_message: |
| message += ( |
| ". This usually means the configured model is unavailable, deprecated, " |
| "or misspelled for this provider." |
| ) |
| return message |
|
|
|
|
| def extract_json_object(text: str) -> str: |
| stripped = text.strip() |
| if stripped.startswith("```"): |
| stripped = re.sub(r"^```(?:json)?", "", stripped, flags=re.IGNORECASE).strip() |
| stripped = re.sub(r"```$", "", stripped).strip() |
| if stripped.startswith("{") and stripped.endswith("}"): |
| return stripped |
|
|
| match = re.search(r"\{.*\}", stripped, flags=re.DOTALL) |
| if not match: |
| raise ValueError("LLM response did not contain a JSON object.") |
| return match.group(0) |
|
|
|
|
| def _build_user_prompt(*, instruction: str, class_names: list[str]) -> str: |
| class_hint = ", ".join(class_names) if class_names else "Use labels from the request." |
| return f"""Available detection labels: {class_hint} |
| |
| Automation request: |
| {instruction} |
| |
| Return a JSON object matching this high-level shape: |
| {{ |
| "rules": [ |
| {{ |
| "name": "short-kebab-case-name", |
| "when": {{"all": [{{"present": {{"label": "cat", "min_count": 1}}}}]}}, |
| "trigger": {{"on": "enter"}}, |
| "gate": {{"enabled": true}}, |
| "then": [{{"type": "simulate", "name": "action name"}}] |
| }} |
| ] |
| }} |
| |
| Examples: |
| |
| User: If person near steering wheel then you have to turn on pc. |
| JSON: |
| {{ |
| "rules": [ |
| {{ |
| "name": "person-near-steering-wheel", |
| "when": {{ |
| "all": [ |
| {{"present": {{"label": "person", "min_count": 1}}}}, |
| {{"near": {{"a": "person", "b": "steering wheel", "max_gap_percent": 16}}}} |
| ] |
| }}, |
| "trigger": {{"on": "enter"}}, |
| "gate": {{"enabled": true}}, |
| "then": [{{"type": "simulate", "name": "turn on pc"}}] |
| }} |
| ] |
| }} |
| |
| User: If package is at door and 15 minutes since last notification, notify me. |
| JSON: |
| {{ |
| "rules": [ |
| {{ |
| "name": "package-at-door", |
| "when": {{ |
| "all": [ |
| {{"present": {{"label": "package", "min_count": 1}}}}, |
| {{"near": {{"a": "package", "b": "door", "max_gap_percent": 16}}}} |
| ] |
| }}, |
| "trigger": {{"on": "while"}}, |
| "gate": {{"enabled": true, "cooldown": {{"key": "package-at-door", "minutes": 15}}}}, |
| "then": [{{"type": "simulate", "name": "notify me"}}] |
| }} |
| ] |
| }} |
| |
| User: While there is a guitar in the scene, amplifier must be on. |
| JSON: |
| {{ |
| "rules": [ |
| {{ |
| "name": "guitar-amplifier-on", |
| "when": {{ |
| "all": [ |
| {{"present": {{"label": "guitar", "min_count": 1}}}} |
| ] |
| }}, |
| "trigger": {{"on": "enter"}}, |
| "gate": {{"enabled": true}}, |
| "then": [{{"type": "simulate", "name": "turn on amplifier"}}] |
| }} |
| ] |
| }} |
| |
| User: If a car is moving, notify me. |
| JSON: |
| {{ |
| "rules": [ |
| {{ |
| "name": "car-moving", |
| "when": {{ |
| "all": [ |
| {{"moving": {{"label": "car", "min_displacement_ratio": 0.15, "window_frames": 3, "max_missing_frames": 1}}}} |
| ] |
| }}, |
| "trigger": {{"on": "enter"}}, |
| "gate": {{"enabled": true}}, |
| "then": [{{"type": "simulate", "name": "notify me"}}] |
| }} |
| ] |
| }} |
| |
| User: If person is near monitor turn on lights. When they leave, turn off lights. |
| JSON: |
| {{ |
| "rules": [ |
| {{ |
| "name": "monitor-presence-lights", |
| "when": {{ |
| "all": [ |
| {{"near": {{"a": "person", "b": "monitor", "max_gap_percent": 16}}}} |
| ] |
| }}, |
| "trigger": {{"on": "change"}}, |
| "gate": {{"enabled": true}}, |
| "then": {{ |
| "enter": [{{"type": "simulate", "name": "turn on lights"}}], |
| "exit": [{{"type": "simulate", "name": "turn off lights"}}] |
| }} |
| }} |
| ] |
| }} |
| |
| Full validation schema: |
| {json.dumps(automation_schema(), indent=2)} |
| """ |
|
|
|
|
| def _build_repair_prompt(*, original_prompt: str, bad_response: str, error: str) -> str: |
| return f"""{original_prompt} |
| |
| Your previous response failed validation. |
| |
| Validation error: |
| {error} |
| |
| Previous response: |
| {bad_response} |
| |
| Return corrected JSON only. The root object MUST include a non-empty "rules" array. |
| """ |
|
|
|
|
| def _chat_payload(*, model: str, user_prompt: str, response_format: str) -> dict[str, Any]: |
| payload: dict[str, Any] = { |
| "model": model, |
| "messages": [ |
| {"role": "system", "content": SYSTEM_PROMPT}, |
| {"role": "user", "content": user_prompt}, |
| ], |
| "max_tokens": 512, |
| "temperature": 0, |
| "stream": False, |
| } |
| if response_format == "json_schema": |
| payload["response_format"] = { |
| "type": "json_schema", |
| "json_schema": { |
| "name": "tiny_trigger_automation", |
| "strict": True, |
| "schema": automation_schema(), |
| }, |
| } |
| else: |
| payload["response_format"] = {"type": "json_object"} |
| return payload |
|
|