From a5ac100b0681a61063faee809c36335d3a932087 Mon Sep 17 00:00:00 2001 From: SMKRV Date: Tue, 30 Dec 2025 16:42:32 +0300 Subject: [PATCH] feat: Add structured output support with JSON schema validation - Introduced `structured_output` and `json_schema` parameters to enhance API responses. - Updated service schemas and API client methods to handle structured output. - Added translations for new parameters in multiple languages. - Updated documentation to reflect changes in service capabilities. Closes #9 --- custom_components/ha_text_ai/__init__.py | 4 + custom_components/ha_text_ai/api_client.py | 92 ++++++++++++++++++- custom_components/ha_text_ai/const.py | 6 +- custom_components/ha_text_ai/coordinator.py | 61 +++++------- custom_components/ha_text_ai/manifest.json | 9 +- custom_components/ha_text_ai/services.yaml | 16 ++++ .../ha_text_ai/translations/de.json | 8 ++ .../ha_text_ai/translations/en.json | 8 ++ .../ha_text_ai/translations/es.json | 8 ++ .../ha_text_ai/translations/hi.json | 8 ++ .../ha_text_ai/translations/it.json | 8 ++ .../ha_text_ai/translations/ru.json | 8 ++ .../ha_text_ai/translations/sr.json | 8 ++ .../ha_text_ai/translations/zh.json | 8 ++ 14 files changed, 205 insertions(+), 47 deletions(-) diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 92bfb3a..372b93e 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -76,6 +76,8 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({ vol.Optional("temperature"): cv.positive_float, vol.Optional("max_tokens"): cv.positive_int, vol.Optional("context_messages"): cv.positive_int, + vol.Optional("structured_output", default=False): cv.boolean, + vol.Optional("json_schema"): cv.string, }) SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({ @@ -127,6 +129,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: max_tokens=call.data.get("max_tokens"), system_prompt=call.data.get("system_prompt"), context_messages=call.data.get("context_messages"), + structured_output=call.data.get("structured_output", False), + json_schema=call.data.get("json_schema"), ) # Return structured response data diff --git a/custom_components/ha_text_ai/api_client.py b/custom_components/ha_text_ai/api_client.py index 0f2e5d3..7e05ec4 100644 --- a/custom_components/ha_text_ai/api_client.py +++ b/custom_components/ha_text_ai/api_client.py @@ -127,6 +127,8 @@ class APIClient: messages: List[Dict[str, str]], temperature: float, max_tokens: int, + structured_output: bool = False, + json_schema: Optional[str] = None, ) -> Dict[str, Any]: """Create completion using appropriate API.""" try: @@ -134,19 +136,23 @@ class APIClient: if self.api_provider == API_PROVIDER_ANTHROPIC: return await self._create_anthropic_completion( - model, messages, temperature, max_tokens + model, messages, temperature, max_tokens, + structured_output, json_schema ) elif self.api_provider == API_PROVIDER_DEEPSEEK: return await self._create_deepseek_completion( - model, messages, temperature, max_tokens + model, messages, temperature, max_tokens, + structured_output, json_schema ) elif self.api_provider == API_PROVIDER_GEMINI: return await self._create_gemini_completion( - model, messages, temperature, max_tokens + model, messages, temperature, max_tokens, + structured_output, json_schema ) else: return await self._create_openai_completion( - model, messages, temperature, max_tokens + model, messages, temperature, max_tokens, + structured_output, json_schema ) except Exception as e: _LOGGER.error("API request failed: %s", str(e)) @@ -158,6 +164,8 @@ class APIClient: messages: List[Dict[str, str]], temperature: float, max_tokens: int, + structured_output: bool = False, + json_schema: Optional[str] = None, ) -> Dict[str, Any]: """Create completion using DeepSeek API.""" url = f"{self.endpoint}/chat/completions" @@ -169,6 +177,24 @@ class APIClient: "stream": False } + # Add structured output format if enabled (DeepSeek is OpenAI-compatible) + if structured_output and json_schema: + try: + import json + schema = json.loads(json_schema) + payload["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": "structured_response", + "strict": True, + "schema": schema + } + } + _LOGGER.debug("DeepSeek structured output enabled with schema") + except json.JSONDecodeError as e: + _LOGGER.warning(f"Invalid JSON schema provided: {e}. Falling back to json_object mode.") + payload["response_format"] = {"type": "json_object"} + data = await self._make_request(url, payload) return { "choices": [ @@ -189,6 +215,8 @@ class APIClient: messages: List[Dict[str, str]], temperature: float, max_tokens: int, + structured_output: bool = False, + json_schema: Optional[str] = None, ) -> Dict[str, Any]: """Create completion using OpenAI API.""" url = f"{self.endpoint}/chat/completions" @@ -199,6 +227,24 @@ class APIClient: "max_tokens": max_tokens, } + # Add structured output format if enabled + if structured_output and json_schema: + try: + import json + schema = json.loads(json_schema) + payload["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": "structured_response", + "strict": True, + "schema": schema + } + } + _LOGGER.debug("OpenAI structured output enabled with schema") + except json.JSONDecodeError as e: + _LOGGER.warning(f"Invalid JSON schema provided: {e}. Falling back to json_object mode.") + payload["response_format"] = {"type": "json_object"} + data = await self._make_request(url, payload) return { "choices": [ @@ -219,6 +265,8 @@ class APIClient: messages: List[Dict[str, str]], temperature: float, max_tokens: int, + structured_output: bool = False, + json_schema: Optional[str] = None, ) -> Dict[str, Any]: """Create completion using Anthropic API.""" url = f"{self.endpoint}/v1/messages" @@ -234,6 +282,20 @@ class APIClient: else: filtered_messages.append(msg) + # For Anthropic, add structured output instruction to system prompt + if structured_output and json_schema: + schema_instruction = ( + f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches " + f"this JSON Schema:\n{json_schema}\n" + f"Do not include any text before or after the JSON. " + f"Do not wrap the JSON in markdown code blocks." + ) + if system_prompt: + system_prompt += schema_instruction + else: + system_prompt = schema_instruction.strip() + _LOGGER.debug("Anthropic structured output enabled via system prompt") + payload = { "model": model, "messages": filtered_messages, @@ -273,6 +335,8 @@ class APIClient: messages: List[Dict[str, str]], temperature: float, max_tokens: int, + structured_output: bool = False, + json_schema: Optional[str] = None, ) -> Dict[str, Any]: """Create completion using Gemini API with google-genai library. @@ -281,6 +345,8 @@ class APIClient: messages: List of message dictionaries with role and content temperature: Sampling temperature between 0.0 and 2.0 max_tokens: Maximum number of tokens to generate + structured_output: Enable JSON structured output mode + json_schema: JSON Schema for structured output validation Returns: Dictionary with response content and token usage @@ -319,6 +385,16 @@ class APIClient: "parts": [{"text": msg['content']}] }) + # Parse JSON schema if structured output is enabled + parsed_schema = None + if structured_output and json_schema: + try: + import json + parsed_schema = json.loads(json_schema) + _LOGGER.debug("Gemini structured output enabled with schema") + except json.JSONDecodeError as e: + _LOGGER.warning(f"Invalid JSON schema provided: {e}. Structured output disabled.") + # Create configuration def create_config(): from google.genai import types @@ -331,6 +407,11 @@ class APIClient: if system_instruction: config.system_instruction = system_instruction.strip() + # Add structured output configuration for Gemini + if structured_output and parsed_schema: + config.response_mime_type = "application/json" + config.response_schema = parsed_schema + return config config = await asyncio.to_thread(create_config) @@ -407,4 +488,5 @@ class APIClient: async def shutdown(self) -> None: """Shutdown API client.""" _LOGGER.debug("Shutting down API client") - await self.session.close() + self._closed = True + # Do NOT close the shared Home Assistant session diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index aee3015..275e544 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -66,6 +66,8 @@ CONF_INSTANCE: Final = "instance" CONF_MAX_HISTORY_SIZE: Final = "max_history_size" # Correct constant name CONF_IS_ANTHROPIC: Final = "is_anthropic" CONF_CONTEXT_MESSAGES: Final = "context_messages" +CONF_STRUCTURED_OUTPUT: Final = "structured_output" +CONF_JSON_SCHEMA: Final = "json_schema" ABSOLUTE_MAX_HISTORY_SIZE = 500 MAX_ATTRIBUTE_SIZE = 4 * 1024 @@ -199,7 +201,9 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({ vol.Optional("context_messages"): vol.All( vol.Coerce(int), vol.Range(min=1, max=20) - ) + ), + vol.Optional(CONF_STRUCTURED_OUTPUT, default=False): cv.boolean, + vol.Optional(CONF_JSON_SCHEMA): cv.string, }) SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({ diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index 8d09977..f0902e9 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -189,16 +189,10 @@ class HATextAICoordinator(DataUpdateCoordinator): # Maximum history file size (1 MB) from const.py self._max_history_file_size = MAX_HISTORY_FILE_SIZE - # Asynchronous file initialization - hass.async_create_task(self.async_initialize_history_file()) + self.context_messages = context_messages _LOGGER.info(f"Initialized HA Text AI coordinator with instance: {instance_name}") - # Register instance - self.hass.data.setdefault(DOMAIN, {}) - self.hass.data[DOMAIN][instance_name] = self - self.context_messages = context_messages - @property def last_response(self) -> Dict[str, Any]: """ @@ -826,6 +820,8 @@ class HATextAICoordinator(DataUpdateCoordinator): max_tokens: Optional[int] = None, system_prompt: Optional[str] = None, context_messages: Optional[int] = None, + structured_output: bool = False, + json_schema: Optional[str] = None, ) -> dict: """ Process a question with optional parameters. @@ -841,12 +837,15 @@ class HATextAICoordinator(DataUpdateCoordinator): max_tokens: Optional maximum response length system_prompt: Optional system-level instruction context_messages: Optional number of context messages to include + structured_output: Enable JSON structured output mode + json_schema: JSON Schema for structured output validation Returns: Full response dictionary from the AI """ return await self.async_process_question( - question, model, temperature, max_tokens, system_prompt, context_messages + question, model, temperature, max_tokens, system_prompt, context_messages, + structured_output, json_schema ) async def async_process_question( @@ -857,6 +856,8 @@ class HATextAICoordinator(DataUpdateCoordinator): max_tokens: Optional[int] = None, system_prompt: Optional[str] = None, context_messages: Optional[int] = None, + structured_output: bool = False, + json_schema: Optional[str] = None, ) -> dict: """Process question with context management.""" if self.client is None: @@ -895,6 +896,8 @@ class HATextAICoordinator(DataUpdateCoordinator): "temperature": temp_temperature, "max_tokens": temp_max_tokens, "messages": messages, + "structured_output": structured_output, + "json_schema": json_schema, } response = await self.async_process_message(question, **kwargs) @@ -909,7 +912,7 @@ class HATextAICoordinator(DataUpdateCoordinator): return response except Exception as err: - self._handle_error(err) + await self._handle_error(err) raise HomeAssistantError(f"Failed to process question: {err}") finally: @@ -919,11 +922,15 @@ class HATextAICoordinator(DataUpdateCoordinator): async def async_process_message(self, question: str, **kwargs) -> dict: """Process message using the AI client.""" try: + structured_output = kwargs.pop("structured_output", False) + json_schema = kwargs.pop("json_schema", None) + async with asyncio.timeout(self.api_timeout): - if self.is_anthropic: - response = await self._process_anthropic_message(question, **kwargs) - else: - response = await self._process_openai_message(question, **kwargs) + # APIClient.create() handles provider routing internally + response = await self._process_openai_message( + question, structured_output=structured_output, + json_schema=json_schema, **kwargs + ) # Add timestamp and model information to response timestamp = dt_util.utcnow().isoformat() @@ -959,30 +966,8 @@ class HATextAICoordinator(DataUpdateCoordinator): await self._handle_error(err) raise - async def _process_anthropic_message(self, question: str, **kwargs) -> dict: - """Process message using Anthropic API.""" - try: - _LOGGER.debug(f"Anthropic API call: model={kwargs['model']}, max_tokens={kwargs['max_tokens']}") - response = await self.client.messages.create( - model=kwargs["model"], - max_tokens=kwargs["max_tokens"], - messages=kwargs["messages"], - temperature=kwargs["temperature"], - ) - _LOGGER.debug(f"Anthropic response: tokens={response.usage}") - return { - "content": response.content[0].text, - "tokens": { - "prompt": response.usage.input_tokens, - "completion": response.usage.output_tokens, - "total": response.usage.input_tokens + response.usage.output_tokens, - }, - } - except Exception as e: - _LOGGER.error(f"Anthropic API error: {str(e)}") - raise - - async def _process_openai_message(self, question: str, **kwargs) -> dict: + async def _process_openai_message(self, question: str, structured_output: bool = False, + json_schema: Optional[str] = None, **kwargs) -> dict: """Process message using OpenAI API.""" try: response = await self.client.create( @@ -990,6 +975,8 @@ class HATextAICoordinator(DataUpdateCoordinator): messages=kwargs["messages"], temperature=kwargs["temperature"], max_tokens=kwargs["max_tokens"], + structured_output=structured_output, + json_schema=json_schema, ) return { diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index c98f9f6..55f2fb1 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -14,12 +14,13 @@ "mqtt": [], "quality_scale": "silver", "requirements": [ - "openai>=1.12.0", - "anthropic>=0.8.0", - "google-genai>=1.16.0", + "aiofiles>=23.0.0", "aiohttp>=3.8.0", + "anthropic>=0.8.0", "async-timeout>=4.0.0", - "certifi>=2024.2.2" + "certifi>=2024.2.2", + "google-genai>=1.16.0", + "openai>=1.12.0" ], "single_config_entry": false, "ssdp": [], diff --git a/custom_components/ha_text_ai/services.yaml b/custom_components/ha_text_ai/services.yaml index 9bcd342..c5b25ac 100644 --- a/custom_components/ha_text_ai/services.yaml +++ b/custom_components/ha_text_ai/services.yaml @@ -74,6 +74,22 @@ ask_question: step: 1 mode: box + structured_output: + name: Structured Output + description: Enable JSON structured output mode. When enabled, the AI will respond with valid JSON matching the provided schema. + required: false + default: false + selector: + boolean: + + json_schema: + name: JSON Schema + description: JSON Schema defining the structure of the expected response. Required when structured_output is enabled. Example: {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]} + required: false + selector: + text: + multiline: true + clear_history: name: Clear History description: >- diff --git a/custom_components/ha_text_ai/translations/de.json b/custom_components/ha_text_ai/translations/de.json index 2b3464a..58e0300 100644 --- a/custom_components/ha_text_ai/translations/de.json +++ b/custom_components/ha_text_ai/translations/de.json @@ -130,6 +130,14 @@ "max_tokens": { "name": "Max Tokens", "description": "Maximale Länge der Antwort (1-100000 Token)" + }, + "structured_output": { + "name": "Strukturierte Ausgabe", + "description": "JSON-Strukturausgabemodus aktivieren. Bei Aktivierung antwortet die KI mit gültigem JSON, das dem angegebenen Schema entspricht." + }, + "json_schema": { + "name": "JSON-Schema", + "description": "JSON-Schema, das die Struktur der erwarteten Antwort definiert. Erforderlich wenn structured_output aktiviert ist." } } }, diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index 5bfdc49..dc7aa50 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -130,6 +130,14 @@ "max_tokens": { "name": "Max Tokens", "description": "Maximum length of the response (1-100000 tokens)" + }, + "structured_output": { + "name": "Structured Output", + "description": "Enable JSON structured output mode. When enabled, the AI will respond with valid JSON matching the provided schema." + }, + "json_schema": { + "name": "JSON Schema", + "description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled." } } }, diff --git a/custom_components/ha_text_ai/translations/es.json b/custom_components/ha_text_ai/translations/es.json index 4ba2a8b..c2f7fcd 100644 --- a/custom_components/ha_text_ai/translations/es.json +++ b/custom_components/ha_text_ai/translations/es.json @@ -130,6 +130,14 @@ "max_tokens": { "name": "Máx. Tokens", "description": "Longitud máxima de la respuesta (1-100000 tokens)" + }, + "structured_output": { + "name": "Salida Estructurada", + "description": "Habilitar modo de salida JSON estructurada. Cuando está habilitado, la IA responderá con JSON válido que coincida con el esquema proporcionado." + }, + "json_schema": { + "name": "Esquema JSON", + "description": "Esquema JSON que define la estructura de la respuesta esperada. Requerido cuando structured_output está habilitado." } } }, diff --git a/custom_components/ha_text_ai/translations/hi.json b/custom_components/ha_text_ai/translations/hi.json index bcf5ac1..459347e 100644 --- a/custom_components/ha_text_ai/translations/hi.json +++ b/custom_components/ha_text_ai/translations/hi.json @@ -121,6 +121,14 @@ "max_tokens": { "name": "अधिकतम टोकन", "description": "प्रतिक्रिया की अधिकतम लंबाई (1-100000 टोकन)" + }, + "structured_output": { + "name": "संरचित आउटपुट", + "description": "JSON संरचित आउटपुट मोड सक्षम करें। सक्षम होने पर, AI प्रदान किए गए स्कीमा से मेल खाने वाले वैध JSON के साथ प्रतिक्रिया देगा।" + }, + "json_schema": { + "name": "JSON स्कीमा", + "description": "अपेक्षित प्रतिक्रिया की संरचना को परिभाषित करने वाला JSON स्कीमा। structured_output सक्षम होने पर आवश्यक।" } } }, diff --git a/custom_components/ha_text_ai/translations/it.json b/custom_components/ha_text_ai/translations/it.json index 73715ed..c5821ce 100644 --- a/custom_components/ha_text_ai/translations/it.json +++ b/custom_components/ha_text_ai/translations/it.json @@ -130,6 +130,14 @@ "max_tokens": { "name": "Token massimi", "description": "Lunghezza massima della risposta (1-100000 token)" + }, + "structured_output": { + "name": "Output Strutturato", + "description": "Abilita la modalità di output JSON strutturato. Quando abilitato, l'IA risponderà con JSON valido corrispondente allo schema fornito." + }, + "json_schema": { + "name": "Schema JSON", + "description": "Schema JSON che definisce la struttura della risposta attesa. Richiesto quando structured_output è abilitato." } } }, diff --git a/custom_components/ha_text_ai/translations/ru.json b/custom_components/ha_text_ai/translations/ru.json index 9406ece..c73d626 100644 --- a/custom_components/ha_text_ai/translations/ru.json +++ b/custom_components/ha_text_ai/translations/ru.json @@ -130,6 +130,14 @@ "max_tokens": { "name": "Максимум токенов", "description": "Максимальная длина ответа (1-100000 токенов)" + }, + "structured_output": { + "name": "Структурированный вывод", + "description": "Включить режим структурированного JSON-вывода. При включении ИИ будет отвечать валидным JSON, соответствующим указанной схеме." + }, + "json_schema": { + "name": "JSON Schema", + "description": "JSON-схема, определяющая структуру ожидаемого ответа. Обязательна при включении structured_output." } } }, diff --git a/custom_components/ha_text_ai/translations/sr.json b/custom_components/ha_text_ai/translations/sr.json index f24695b..74f6ce2 100644 --- a/custom_components/ha_text_ai/translations/sr.json +++ b/custom_components/ha_text_ai/translations/sr.json @@ -121,6 +121,14 @@ "max_tokens": { "name": "Максимални токени", "description": "Максимална дужина одговора (1-100000 токена)" + }, + "structured_output": { + "name": "Структурисани излаз", + "description": "Омогући JSON структурисани излаз. Када је омогућено, AI ће одговарати валидним JSON-ом који одговара датој шеми." + }, + "json_schema": { + "name": "JSON шема", + "description": "JSON шема која дефинише структуру очекиваног одговора. Обавезна када је structured_output омогућен." } } }, diff --git a/custom_components/ha_text_ai/translations/zh.json b/custom_components/ha_text_ai/translations/zh.json index 537d6ae..77aea0e 100644 --- a/custom_components/ha_text_ai/translations/zh.json +++ b/custom_components/ha_text_ai/translations/zh.json @@ -121,6 +121,14 @@ "max_tokens": { "name": "最大标记数", "description": "响应的最大长度(1-100000个标记)" + }, + "structured_output": { + "name": "结构化输出", + "description": "启用JSON结构化输出模式。启用后,AI将以符合提供的模式的有效JSON进行响应。" + }, + "json_schema": { + "name": "JSON模式", + "description": "定义预期响应结构的JSON模式。启用structured_output时必需。" } } },