Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add native functions for basic file IO and math calculation #280

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 106 additions & 31 deletions custom_components/extended_openai_conversation/helpers.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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(),
Expand All @@ -752,4 +827,4 @@ async def execute(
"scrape": ScrapeFunctionExecutor(),
"composite": CompositeFunctionExecutor(),
"sqlite": SqliteFunctionExecutor(),
}
}
3 changes: 2 additions & 1 deletion custom_components/extended_openai_conversation/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
60 changes: 60 additions & 0 deletions examples/calculator/README.md
Original file line number Diff line number Diff line change
@@ -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.
```
61 changes: 61 additions & 0 deletions examples/function/file operations/README.md
Original file line number Diff line number Diff line change
@@ -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.
```