diff --git a/custom_components/extended_openai_conversation/helpers.py b/custom_components/extended_openai_conversation/helpers.py index 917ac11..12522aa 100644 --- a/custom_components/extended_openai_conversation/helpers.py +++ b/custom_components/extended_openai_conversation/helpers.py @@ -1,11 +1,13 @@ from abc import ABC, abstractmethod from datetime import timedelta from functools import partial +import ast import logging import os import re import sqlite3 import time +import aiofiles from typing import Any from urllib import parse @@ -195,6 +197,18 @@ class NativeFunctionExecutor(FunctionExecutor): def __init__(self) -> None: """initialize native function""" super().__init__(vol.Schema({vol.Required("name"): str})) + self.native_functions = { + "execute_service": self.execute_service, + "execute_service_single": self.execute_service_single, + "add_automation": self.add_automation, + "get_history": self.get_history, + "get_energy": self.get_energy, + "get_statistics": self.get_statistics, + "get_user_from_user_id": self.get_user_from_user_id, + "write_to_file": self.write_to_file, + "read_from_file": self.read_from_file, + "calculate": self.calculate + } async def execute( self, @@ -205,36 +219,15 @@ async def execute( exposed_entities, ): name = function["name"] - if name == "execute_service": - return await self.execute_service( - hass, function, arguments, user_input, exposed_entities - ) - if name == "execute_service_single": - return await self.execute_service_single( - hass, function, arguments, user_input, exposed_entities - ) - if name == "add_automation": - return await self.add_automation( - hass, function, arguments, user_input, exposed_entities - ) - if name == "get_history": - return await self.get_history( - hass, function, arguments, user_input, exposed_entities - ) - if name == "get_energy": - return await self.get_energy( - hass, function, arguments, user_input, exposed_entities - ) - if name == "get_statistics": - return await self.get_statistics( - hass, function, arguments, user_input, exposed_entities - ) - if name == "get_user_from_user_id": - return await self.get_user_from_user_id( - hass, function, arguments, user_input, exposed_entities - ) - raise NativeNotFound(name) + native_function = self.native_functions.get(name) + if not native_function: + raise NativeNotFound(name) + + result = await native_function( + hass, function, arguments, user_input, exposed_entities + ) + return result async def execute_service_single( self, @@ -416,6 +409,59 @@ async def get_statistics( arguments.get("types", {"change"}), ) + async def write_to_file( + self, + hass: HomeAssistant, + function, + arguments, + user_input: conversation.ConversationInput, + exposed_entities, + ): + """Write content to a file asynchronously. + + Args: + arguments: Dictionary containing: + filename: Path to the file + content: Content to write + open_mode: Optional file mode ('w', 'a', 'w+', 'a+'). Defaults to 'w' + """ + filename = arguments["filename"] + content = arguments["content"] + open_mode = arguments.get("open_mode", "w") + + # Validate file mode for safety + allowed_modes = {"w", "a", "w+", "a+"} + if open_mode not in allowed_modes: + raise ValueError(f"Invalid open mode '{open_mode}'. Allowed modes are: {', '.join(allowed_modes)}") + + try: + full_path = os.path.abspath(filename) + _LOGGER.info("Writing to file: %s, open_mode: %s, content: %s", full_path, open_mode, content) + async with aiofiles.open(full_path, open_mode) as f: + await f.write(content) + return "Success" + except (IOError, OSError) as e: + error_msg = f"Failed to write to file {full_path}: {str(e)}" + _LOGGER.error(error_msg) + raise HomeAssistantError(error_msg) from e + + async def read_from_file( + self, + hass: HomeAssistant, + function, + arguments, + user_input: conversation.ConversationInput, + exposed_entities, + ): + """Read content from a file asynchronously.""" + filename = arguments["filename"] + full_path = os.path.abspath(filename) + _LOGGER.info("Reading from file: %s", full_path) + async with aiofiles.open(full_path, "r") as f: + content = await f.read() + _LOGGER.info("File content: %s", content) + return content + def as_utc(self, value: str, default_value, parse_error_message: str): if value is None: return default_value @@ -431,6 +477,36 @@ def as_dict(self, state: State | dict[str, Any]): return state.as_dict() return state + def calculate(self, hass: HomeAssistant, function, arguments, user_input: conversation.ConversationInput, exposed_entities): + expression = function["expression"] + try: + if not self.is_math_expr(expression): + raise HomeAssistantError("Expression is not a valid mathematical expression.") + result = eval(expression) + return result + except Exception as e: + raise HomeAssistantError(f"Error evaluating math expression: {str(e)}") + + def is_math_expr(self, expr): + """ + Determines if a given Python expression is a mathematical expression. + + Args: + expr (str): The input expression as a string. + + Returns: + bool: True if the expression is mathematical, False otherwise. + """ + allowed_node_types = ( + ast.Expression, ast.BinOp, ast.UnaryOp, ast.Module, ast.Constant, + ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.Mod, ast.USub + ) + + try: + tree = ast.parse(expr, mode='eval') + return all(isinstance(x, allowed_node_types) for x in ast.walk(tree)) + except Exception: + return False class ScriptFunctionExecutor(FunctionExecutor): def __init__(self) -> None: @@ -743,7 +819,6 @@ async def execute( result.append({name: val for name, val in zip(names, row)}) return result - FUNCTION_EXECUTORS: dict[str, FunctionExecutor] = { "native": NativeFunctionExecutor(), "script": ScriptFunctionExecutor(), @@ -752,4 +827,4 @@ async def execute( "scrape": ScrapeFunctionExecutor(), "composite": CompositeFunctionExecutor(), "sqlite": SqliteFunctionExecutor(), -} +} \ No newline at end of file diff --git a/custom_components/extended_openai_conversation/manifest.json b/custom_components/extended_openai_conversation/manifest.json index 389ca1c..3846cd7 100644 --- a/custom_components/extended_openai_conversation/manifest.json +++ b/custom_components/extended_openai_conversation/manifest.json @@ -18,7 +18,8 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/jekalmin/extended_openai_conversation/issues", "requirements": [ - "openai~=1.3.8" + "openai~=1.3.8", + "aiofiles>=24.1.0" ], "version": "1.0.4" } \ No newline at end of file diff --git a/examples/calculator/README.md b/examples/calculator/README.md new file mode 100644 index 0000000..5174e14 --- /dev/null +++ b/examples/calculator/README.md @@ -0,0 +1,60 @@ +## Objective +- Calculate a simple math expression and return the calculation result as a number. + + +## Functions + +### write_to_file +```yaml +- spec: + name: calculate + description: calculate a math expression and return the calculation result as a number. + strict: true + parameters: + type: object + additionalProperties: false + properties: + expression: + type: string + description: a legal Python expression that contains only common math operators (+, -, *, /, **, %), numbers, and brackets. + required: + - expression + function: + type: native + name: calculate +``` + +## Example +### Prompt +... +When the inquiry implies math calculation (e.g., "how much in total did I make over the past week?"), always call function calculate to get the result. You can do that by formulate the calculation as a Python math expression and evaluate it by calling function "calculate". Do not ask user to confirm if calculation is needed. +``` +### Sample Conversation 1 +``` +User: +What's 247 divided by 4, and then powered by 5? + +Assistant: +The result of 247 divided by 4, and then raised to the power of 5, is approximately 897,810,767.58. +``` + +### Sample Conversation 2 +``` +User: +How much milk did the baby drink today? + +Assistant: +I found the following entries in the memo regarding how much the baby drank today: + +- 130 ml at 00:30 +- 125 ml at 05:00 +- 110 ml at 13:00 +- 125 ml at 06:00 +- 130 ml at 21:00 + +I added these amounts together: + +130 + 125 + 110 + 125 + 130 = 620 ml. + +That's how I reached the total of 620 milliliters. +``` \ No newline at end of file diff --git a/examples/function/file operations/README.md b/examples/function/file operations/README.md new file mode 100644 index 0000000..17860c6 --- /dev/null +++ b/examples/function/file operations/README.md @@ -0,0 +1,61 @@ +## Objective +- Use file read/write functions to implement features such as persistent memory. + + +## Functions + +### write_to_file +```yaml +- spec: + name: write_to_file + description: write the provided content to the specified file. A new file will be created if one does not exist. + parameters: + type: object + properties: + filename: + type: string + description: a filename consists of 2-32 alphanumerical characters and underscore. + content: + type: string + description: the content to be written. + open_mode: + type: string + description: optional file mode ('w', 'a', 'w+', 'a+'). Defaults to 'w' + required: + - filename + - content + function: + type: native + name: write_to_file +``` + +### read_from_file +```yaml +- spec: + name: read_from_file + description: read text from the specified file. + parameters: + type: object + properties: + filename: + type: string + description: a filename consists of 2-32 alphanumerical characters. + required: + - filename + function: + type: native + name: read_from_file +``` + +## Sample prompts +``` +When explicitly requested, help user remember things by appending a new row (fields "timestamp" and "content") using write_to_file function to the file "assistant_memo.csv". Retrieve later by reading from one or more files with the read_from_file function. + +Example: +User: help me remember that I have taken vitamin today. +(You should call function write_to_file("assistant_memo.csv", "2024-12-28 08:00:05.109809-08:00,\"user has taken vitamin today\"\n", "a") +User: help me remember that I just took vitamin. +(You should call function write_to_file("assistant_memo.csv", "2024-12-28 21:57:01.103210-08:00,\"user just took vitamin\"\n", "a") +User: how many times have I taken vitamin today? +You: Yes, you have taken Vitamin twice today at 8:00 and 21:57 respectively. +``` \ No newline at end of file