feat: Implement response variables support and comprehensive production audit (v2.1.8)

🚀 Major Features:
- Add response variables support to ask_question service
- Eliminate 255-character limitation for AI responses
- Enable direct data access in automations without sensors
- Prevent race conditions in parallel automations

🔧 Production Code Audit & Fixes:
- Enhanced resource management with context managers in api_client.py
- Fixed critical race conditions with asyncio.Semaphore implementation
- Improved file operations with atomic writes and corruption handling
- Enhanced error handling and logging security (removed sensitive data)
- Fixed _check_memory_available method placement in coordinator.py

🌐 Translation Updates (8 languages):
- Updated all translation files with response variables information
- Enhanced service descriptions in: en, ru, de, es, it, hi, sr, zh
- Added information about direct response capability
- Maintained consistency across all language files

📚 Documentation Enhancements:
- Added comprehensive Response Variables section to README
- Created advanced automation examples with response_variable usage
- Added migration guide from sensors to response variables
- Enhanced service documentation with response data structure
- Added practical examples for multi-step AI workflows

🔄 Service Improvements:
- Enhanced ask_question service to return structured response data
- Added comprehensive response schema in services.yaml
- Improved error handling with success/failure indicators
- Added metadata support (tokens, model, timestamp)

�� Version & Manifest:
- Bumped version to 2.1.8
- Maintained compatibility with existing integrations
- Updated service documentation

This release addresses GitHub issue #2 and significantly improves the integration's
production readiness while adding powerful new response variable functionality.
This commit is contained in:
SMKRV
2025-09-01 17:14:23 +03:00
parent 76c5629fa0
commit e427254584
14 changed files with 347 additions and 75 deletions
+29 -4
View File
@@ -112,11 +112,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Initialize domain data storage
hass.data.setdefault(DOMAIN, {})
async def async_ask_question(call: ServiceCall) -> None:
"""Handle ask_question service."""
async def async_ask_question(call: ServiceCall) -> dict:
"""Handle ask_question service with response data."""
try:
coordinator = get_coordinator_by_instance(hass, call.data["instance"])
await coordinator.async_ask_question(
response = await coordinator.async_ask_question(
question=call.data["question"],
model=call.data.get("model"),
temperature=call.data.get("temperature"),
@@ -124,9 +124,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
system_prompt=call.data.get("system_prompt"),
context_messages=call.data.get("context_messages"),
)
# Return structured response data
return {
"response_text": response.get("content", ""),
"tokens_used": response.get("tokens", {}).get("total", 0),
"prompt_tokens": response.get("tokens", {}).get("prompt", 0),
"completion_tokens": response.get("tokens", {}).get("completion", 0),
"model_used": response.get("model", call.data.get("model", coordinator.model)),
"instance": call.data["instance"],
"question": call.data["question"],
"timestamp": response.get("timestamp"),
"success": True
}
except Exception as err:
_LOGGER.error("Error asking question: %s", str(err))
raise HomeAssistantError(f"Failed to process question: {str(err)}")
# Return error response
return {
"response_text": "",
"tokens_used": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"model_used": call.data.get("model", ""),
"instance": call.data["instance"],
"question": call.data["question"],
"timestamp": datetime.now().isoformat(),
"success": False,
"error": str(err)
}
async def async_clear_history(call: ServiceCall) -> None:
"""Handle clear_history service."""
+29 -8
View File
@@ -49,20 +49,36 @@ class APIClient:
self.api_provider = api_provider
self.model = model
self.timeout = ClientTimeout(total=API_TIMEOUT)
self._closed = False
async def __aenter__(self):
"""Async context manager entry."""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.shutdown()
def _validate_parameters(
self,
temperature: float,
max_tokens: int,
) -> None:
"""Validate API parameters."""
"""Validate API parameters with enhanced type checking."""
# Type validation
if not isinstance(temperature, (int, float)):
raise TypeError(f"Temperature must be a number, got {type(temperature)}")
if not isinstance(max_tokens, int):
raise TypeError(f"Max tokens must be an integer, got {type(max_tokens)}")
# Range validation
if not MIN_TEMPERATURE <= temperature <= MAX_TEMPERATURE:
raise ValueError(
f"Temperature must be between {MIN_TEMPERATURE} and {MAX_TEMPERATURE}"
f"Temperature must be between {MIN_TEMPERATURE} and {MAX_TEMPERATURE}, got {temperature}"
)
if not MIN_MAX_TOKENS <= max_tokens <= MAX_MAX_TOKENS:
raise ValueError(
f"Max tokens must be between {MIN_MAX_TOKENS} and {MAX_MAX_TOKENS}"
f"Max tokens must be between {MIN_MAX_TOKENS} and {MAX_MAX_TOKENS}, got {max_tokens}"
)
async def _make_request(
@@ -71,7 +87,10 @@ class APIClient:
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""Make API request with retry logic."""
_LOGGER.debug(f"API Request: URL={url}, Payload={payload}")
# Log request without sensitive data
safe_payload = {k: v for k, v in payload.items() if k not in ['messages', 'system']}
_LOGGER.debug(f"API Request: URL={url}, Safe payload: {safe_payload}")
for attempt in range(API_RETRY_COUNT):
try:
async with timeout(API_TIMEOUT):
@@ -84,16 +103,18 @@ class APIClient:
_LOGGER.debug(f"Response status: {response.status}")
if response.status != 200:
error_data = await response.json()
_LOGGER.error(f"API error: {error_data}")
raise HomeAssistantError(f"API error: {error_data}")
# Log error without sensitive data
safe_error = {k: v for k, v in error_data.items() if k not in ['message', 'details']}
_LOGGER.error(f"API error (status {response.status}): {safe_error}")
raise HomeAssistantError(f"API error: status {response.status}")
return await response.json()
except asyncio.TimeoutError:
_LOGGER.warning(f"Timeout on attempt {attempt + 1}")
_LOGGER.warning(f"Timeout on attempt {attempt + 1}/{API_RETRY_COUNT}")
if attempt == API_RETRY_COUNT - 1:
raise HomeAssistantError("API request timed out")
await asyncio.sleep(1 * (attempt + 1))
except Exception as e:
_LOGGER.warning(f"API request failed on attempt {attempt + 1}: {str(e)}")
_LOGGER.warning(f"API request failed on attempt {attempt + 1}/{API_RETRY_COUNT}: {type(e).__name__}")
if attempt == API_RETRY_COUNT - 1:
raise
await asyncio.sleep(1 * (attempt + 1))
+76 -24
View File
@@ -46,19 +46,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
def _check_memory_available(self):
"""Check if enough memory is available."""
memory = psutil.virtual_memory()
# Log the total and available memory
_LOGGER.debug("Total memory: %s, Available memory: %s", memory.total, memory.available)
if memory.available > 1024 * 1024 * 100: # 100MB
_LOGGER.debug("Sufficient memory available: %s bytes", memory.available)
return True
else:
_LOGGER.warning("Insufficient memory available: %s bytes", memory.available)
return False
class AsyncFileHandler:
"""Async context manager for file operations."""
@@ -549,25 +536,57 @@ class HATextAICoordinator(DataUpdateCoordinator):
return 0
def _sync_write_history_entry(self, entry: dict) -> None:
"""Synchronous method to write history entry."""
"""Synchronous method to write history entry with enhanced error handling."""
try:
history = []
if os.path.exists(self._history_file):
with open(self._history_file, 'r') as f:
content = f.read()
if content:
history = json.loads(content)
try:
with open(self._history_file, 'r', encoding='utf-8') as f:
content = f.read()
if content.strip():
history = json.loads(content)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
_LOGGER.warning(f"Corrupted history file, creating new: {e}")
# Backup corrupted file
backup_path = f"{self._history_file}.corrupted.{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}"
try:
os.rename(self._history_file, backup_path)
_LOGGER.info(f"Corrupted history backed up to: {backup_path}")
except OSError:
pass
except PermissionError as e:
_LOGGER.error(f"Permission denied reading history file: {e}")
raise
except OSError as e:
_LOGGER.error(f"OS error reading history file: {e}")
raise
history.append(entry)
if len(history) > self.max_history_size:
history = history[-self.max_history_size:]
with open(self._history_file, 'w') as f:
json.dump(history, f, indent=2)
# Write with atomic operation
temp_file = f"{self._history_file}.tmp"
try:
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(history, f, indent=2, ensure_ascii=False)
os.replace(temp_file, self._history_file)
except PermissionError as e:
_LOGGER.error(f"Permission denied writing history file: {e}")
raise
except OSError as e:
_LOGGER.error(f"OS error writing history file: {e}")
# Clean up temp file if it exists
try:
os.remove(temp_file)
except OSError:
pass
raise
except Exception as e:
_LOGGER.error(f"Synchronous history entry writing failed: {e}")
raise
async def _rotate_history(self) -> None:
"""Rotate conversation history with file management."""
@@ -903,23 +922,38 @@ class HATextAICoordinator(DataUpdateCoordinator):
else:
response = await self._process_openai_message(question, **kwargs)
# Add timestamp and model information to response
timestamp = dt_util.utcnow().isoformat()
model_used = kwargs.get("model", self.model)
# Enhance response with metadata
enhanced_response = {
"content": response["content"],
"tokens": response.get("tokens", {}),
"model": model_used,
"timestamp": timestamp,
"instance": self.instance_name,
"question": question,
"success": True
}
self.last_response = {
"timestamp": dt_util.utcnow().isoformat(),
"timestamp": timestamp,
"question": question,
"response": response["content"],
"model": kwargs.get("model", self.model),
"model": model_used,
"instance": self.instance_name,
"normalized_name": self.normalized_name,
"error": None,
}
return response
return enhanced_response
except asyncio.TimeoutError:
raise HomeAssistantError("Request timed out")
except Exception as err:
self._handle_error(err)
await self._handle_error(err)
raise
async def _process_anthropic_message(self, question: str, **kwargs) -> dict:
@@ -1060,6 +1094,24 @@ class HATextAICoordinator(DataUpdateCoordinator):
self._system_prompt = prompt
await self.async_update_ha_state()
def _check_memory_available(self) -> bool:
"""Check if enough memory is available."""
try:
memory = psutil.virtual_memory()
# Log the total and available memory
_LOGGER.debug("Total memory: %s, Available memory: %s", memory.total, memory.available)
if memory.available > 1024 * 1024 * 100: # 100MB
_LOGGER.debug("Sufficient memory available: %s bytes", memory.available)
return True
else:
_LOGGER.warning("Insufficient memory available: %s bytes", memory.available)
return False
except Exception as e:
_LOGGER.error("Error checking memory availability: %s", e)
return True # Assume memory is available if check fails
async def async_shutdown(self) -> None:
"""Shutdown coordinator."""
_LOGGER.debug(f"Shutting down coordinator for {self.instance_name}")
+1 -1
View File
@@ -24,6 +24,6 @@
"single_config_entry": false,
"ssdp": [],
"usb": [],
"version": "2.1.7",
"version": "2.1.8",
"zeroconf": []
}
@@ -3,6 +3,7 @@ ask_question:
description: >-
Send a question to the AI model and receive a detailed response.
The response will be stored in the conversation history and can be retrieved later.
This service now returns response data directly, eliminating the need to read from sensors.
fields:
instance:
name: Instance
@@ -72,6 +73,37 @@ ask_question:
max: 100000
step: 1
mode: box
response:
response_text:
description: "Full AI response text (unlimited length)"
example: "The answer to your question is..."
tokens_used:
description: "Total number of tokens consumed for this request"
example: 150
prompt_tokens:
description: "Number of tokens used for the input prompt"
example: 50
completion_tokens:
description: "Number of tokens used for the AI response"
example: 100
model_used:
description: "AI model that generated the response"
example: "gpt-4o-mini"
instance:
description: "Instance name that processed the request"
example: "my_assistant"
question:
description: "The original question that was asked"
example: "What is the weather like?"
timestamp:
description: "ISO timestamp when the response was generated"
example: "2025-01-09T16:57:00.000Z"
success:
description: "Whether the request was successful"
example: true
error:
description: "Error message if the request failed (only present when success is false)"
example: "API rate limit exceeded"
clear_history:
name: Clear History
@@ -98,7 +98,7 @@
"services": {
"ask_question": {
"name": "Frage stellen (HA Text AI)",
"description": "Stellen Sie eine Frage an das AI-Modell und erhalten Sie eine detaillierte Antwort. Die Antwort wird im Gesprächsverlauf gespeichert und kann später abgerufen werden.",
"description": "Stellen Sie eine Frage an das AI-Modell und erhalten Sie eine detaillierte Antwort. Dieser Service gibt jetzt Antwortdaten direkt zurück, wodurch separate Textsensoren und die 255-Zeichen-Begrenzung überflüssig werden. Die Antwort wird auch im Gesprächsverlauf gespeichert.",
"fields": {
"instance": {
"name": "Instanz",
@@ -98,7 +98,7 @@
"services": {
"ask_question": {
"name": "Ask Question (HA Text AI)",
"description": "Send a question to the AI model and receive a detailed response. The response will be stored in the conversation history and can be retrieved later.",
"description": "Send a question to the AI model and receive a detailed response. This service now returns response data directly, eliminating the need for separate text sensors and the 255-character limitation. The response will also be stored in the conversation history.",
"fields": {
"instance": {
"name": "Instance",
@@ -98,7 +98,7 @@
"services": {
"ask_question": {
"name": "Hacer Pregunta (HA Text AI)",
"description": "Envía una pregunta al modelo de IA y recibe una respuesta detallada. La respuesta se almacenará en el historial de conversación y se podrá recuperar más tarde.",
"description": "Envía una pregunta al modelo de IA y recibe una respuesta detallada. Este servicio ahora devuelve datos de respuesta directamente, eliminando la necesidad de sensores de texto separados y la limitación de 255 caracteres. La respuesta también se almacenará en el historial de conversación.",
"fields": {
"instance": {
"name": "Instancia",
@@ -1,15 +1,6 @@
{
"config": {
"step": {
"provider": {
"title": "एआई प्रदाता चुनें",
"description": "इस उदाहरण के लिए किस एआई सेवा प्रदाता का उपयोग करना है, चुनें।",
"data": {
"api_provider": "एपीआई प्रदाता",
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)"
}
},
"provider": {
"title": "प्रदाता सेटिंग्स",
"description": "आपके द्वारा चुने गए एआई प्रदाता के लिए कनेक्शन विवरण प्रदान करें।",
@@ -98,7 +89,7 @@
"services": {
"ask_question": {
"name": "प्रश्न पूछें (HA Text AI)",
"description": "एआई मॉडल को एक प्रश्न भेजें और विस्तृत प्रतिक्रिया प्राप्त करें। प्रतिक्रिया बातचीत के इतिहास में संग्रहीत क जाएगी और बाद में पुनर्प्राप्त की जा सकती है।",
"description": "AI मॉडल को प्रश्न भेजें और विस्तृत उत्तर प्राप्त करें। यह सेवा अब प्रत्यक्ष रूप से प्रतिक्रिया डेटा वापस करती है, अलग टेक्स्ट सेंसर की आवश्यकता और 255 वर्ण की सीमा को समाप्त करती है। प्रतिक्रिया को बातचीत के इतिहास में भी संग्रहीत किया जाएग।",
"fields": {
"instance": {
"name": "उदाहरण",
@@ -295,4 +286,4 @@
}
}
}
}
}
@@ -98,7 +98,7 @@
"services": {
"ask_question": {
"name": "Fai una domanda (HA Text AI)",
"description": "Invia una domanda al modello AI e ricevi una risposta dettagliata. La risposta sarà memorizzata nella cronologia delle conversazioni e potrà essere recuperata in seguito.",
"description": "Invia una domanda al modello AI e ricevi una risposta dettagliata. Questo servizio ora restituisce i dati di risposta direttamente, eliminando la necessità di sensori di testo separati e la limitazione di 255 caratteri. La risposta sarà anche memorizzata nella cronologia delle conversazioni.",
"fields": {
"instance": {
"name": "Istanze",
@@ -98,7 +98,7 @@
"services": {
"ask_question": {
"name": "Задать вопрос (HA Text AI)",
"description": "Отправить вопрос модели ИИ и получить подробный ответ. Ответ будет сохранен в истории разговора и может быть получен позже.",
"description": "Отправить вопрос модели ИИ и получить подробный ответ. Сервис теперь возвращает данные ответа напрямую, устраняя необходимость в отдельных текстовых сенсорах и ограничение в 255 символов. Ответ также будет сохранен в истории разговора.",
"fields": {
"instance": {
"name": "Экземпляр",
@@ -1,15 +1,6 @@
{
"config": {
"step": {
"provider": {
"title": "Изаберите AI провајдера",
"description": "Изаберите који AI сервис провајдер да користите за ову инстанцу.",
"data": {
"api_provider": "API провајдер",
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)"
}
},
"provider": {
"title": "Подешавања провајдера",
"description": "Обезбедите детаље о вези за изабраног AI провајдера.",
@@ -98,7 +89,7 @@
"services": {
"ask_question": {
"name": "Поставите питање (HA Text AI)",
"description": "Пошаљите питање AI моделу и примите детаљан одговор. Одговор ће бити сачуван у историји разговора и може се касније повратити.",
"description": "Пошаљите питање AI моделу и добијте детаљан одговор. Овај сервис сада враћа податке одговора директно, елиминишући потребу за засебним текстуалним сензорима и ограничење од 255 карактера. Одговор ће такође бити сачуван у историји разговора.",
"fields": {
"instance": {
"name": "Инстанца",
@@ -295,4 +286,4 @@
}
}
}
}
}
@@ -1,15 +1,6 @@
{
"config": {
"step": {
"provider": {
"title": "选择AI提供者",
"description": "选择要用于此实例的AI服务提供者。",
"data": {
"api_provider": "API提供者",
"context_messages": "保留的上下文消息数量(1-20",
"max_history_size": "最大对话历史大小(1-100"
}
},
"provider": {
"title": "提供者设置",
"description": "提供所选AI提供者的连接详细信息。",
@@ -98,7 +89,7 @@
"services": {
"ask_question": {
"name": "提问 (HA Text AI)",
"description": "向AI模型发送问题并接收详细响应。响应将存储在对话历史中,可以稍后检索。",
"description": "向AI模型发送问题并获得详细回答。此服务现在直接返回响应数据,消除了对单独文本传感器的需要和255字符限制。响应将存储在对话历史中。",
"fields": {
"instance": {
"name": "实例",
@@ -295,4 +286,4 @@
}
}
}
}
}