Spaces:
Sleeping
Sleeping
| 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: # pragma: no cover - dependency guard | |
| 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: # pragma: no cover - dependency guard | |
| 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: # pragma: no cover - dependency guard | |
| 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 | |