Skip to content

Commit

Permalink
Merge pull request #54 from jekalmin/v0.0.9
Browse files Browse the repository at this point in the history
0.0.9
  • Loading branch information
jekalmin authored Dec 19, 2023
2 parents 78f7ac2 + 46aeb46 commit f858745
Show file tree
Hide file tree
Showing 13 changed files with 452 additions and 44 deletions.
59 changes: 57 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ Derived from [OpenAI Conversation](https://www.home-assistant.io/integrations/op
## Additional Features
- Ability to call service of Home Assistant
- Ability to create automation
- Ability to get data from API or web page
- Ability to get data from external API or web page
- Ability to retrieve state history of entities
- Option to pass the current user's name to OpenAI via the user message context

## How it works
Extended OpenAI Conversation uses OpenAI API's feature of [function calling](https://platform.openai.com/docs/guides/function-calling) to call service of Home Assistant.
Expand Down Expand Up @@ -55,6 +57,7 @@ https://github.com/jekalmin/extended_openai_conversation/assets/2917984/64ba656e
By clicking a button from Edit Assist, Options can be customized.<br/>
Options include [OpenAI Conversation](https://www.home-assistant.io/integrations/openai_conversation/) options and two new options.

- `Attach Username`: Pass the active user's name (if applicable) to OpenAI via the message payload. Currently, this only applies to conversations through the UI or REST API.

- `Maximum Function Calls Per Conversation`: limit the number of function calls in a single conversation.
(Sometimes function is called over and over again, possibly running into infinite loop)
Expand All @@ -79,6 +82,13 @@ Options include [OpenAI Conversation](https://www.home-assistant.io/integrations
- `service_data`(string): service_data to be passed to `hass.services.async_call`
- `add_automation`
- `automation_config`(string): An automation configuration in a yaml format
- `get_history`
- `entity_ids`(list): a list of entity ids to filter
- `start_time`(string): defaults to 1 day before the time of the request. It determines the beginning of the period
- `end_time`(string): the end of the period in URL encoded format (defaults to 1 day)
- `minimal_response`(boolean): only return last_changed and state for states other than the first and last state (defaults to true)
- `no_attributes`(boolean): skip returning attributes from the database (defaults to true)
- `significant_changes_only`(boolean): only return significant state changes (defaults to true)
- `script`: A list of services that will be called
- `template`: The value to be returned from function.
- `rest`: Getting data from REST API endpoint.
Expand Down Expand Up @@ -358,14 +368,59 @@ Copy and paste below configuration into "Functions"

<img width="300" alt="스크린샷 2023-10-31 오후 9 32 27" src="https://github.com/jekalmin/extended_openai_conversation/assets/2917984/55f5fe7e-b1fd-43c9-bce6-ac92e203598f">

#### 3-2. Get History
Get state history of entities

```yaml
- spec:
name: get_history
description: Retrieve historical data of specified entities.
parameters:
type: object
properties:
entity_ids:
type: array
items:
type: string
description: The entity id to filter.
start_time:
type: string
description: Start of the history period in "%Y-%m-%dT%H:%M:%S%z".
end_time:
type: string
description: End of the history period in "%Y-%m-%dT%H:%M:%S%z".
required:
- entity_ids
function:
type: composite
sequence:
- type: native
name: get_history
response_variable: history_result
- type: template
value_template: >-
{% set ns = namespace(result = [], list = []) %}
{% for item_list in history_result %}
{% set ns.list = [] %}
{% for item in item_list %}
{% set last_changed = item.last_changed | as_timestamp | timestamp_local if item.last_changed else None %}
{% set new_item = dict(item, last_changed=last_changed) %}
{% set ns.list = ns.list + [new_item] %}
{% endfor %}
{% set ns.result = ns.result + [ns.list] %}
{% endfor %}
{{ ns.result }}
```

<img width="300" src="https://github.com/jekalmin/extended_openai_conversation/assets/2917984/32217f3d-10fc-4001-9028-717b1683573b">

### 4. scrape
#### 4-1. Get current HA version
Scrape version from webpage, "https://www.home-assistant.io"

Unlike [scrape](https://www.home-assistant.io/integrations/scrape/), "value_template" is added at root level in which scraped data from sensors are passed.

```yaml
scrape:
- spec:
name: get_ha_version
description: Use this function to get Home Assistant version
Expand Down
51 changes: 34 additions & 17 deletions custom_components/extended_openai_conversation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, MATCH_ALL
from homeassistant.const import CONF_API_KEY, MATCH_ALL, ATTR_NAME
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.util import ulid
from homeassistant.components.homeassistant.exposed_entities import async_should_expose
Expand All @@ -30,6 +30,7 @@
)

from .const import (
CONF_ATTACH_USERNAME,
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_PROMPT,
Expand All @@ -38,6 +39,7 @@
CONF_MAX_FUNCTION_CALLS_PER_CONVERSATION,
CONF_FUNCTIONS,
CONF_BASE_URL,
DEFAULT_ATTACH_USERNAME,
DEFAULT_CHAT_MODEL,
DEFAULT_MAX_TOKENS,
DEFAULT_PROMPT,
Expand All @@ -54,6 +56,9 @@
CallServiceError,
FunctionNotFound,
NativeNotFound,
FunctionLoadFailed,
ParseArgumentsFailed,
InvalidFunction,
)

from .helpers import (
Expand All @@ -67,6 +72,7 @@
CompositeFunctionExecutor,
convert_to_template,
validate_authentication,
get_function_executor,
)


Expand Down Expand Up @@ -150,12 +156,18 @@ async def async_process(
response=intent_response, conversation_id=conversation_id
)
messages = [{"role": "system", "content": prompt}]
user_message = {"role": "user", "content": user_input.text}
if self.entry.options.get(CONF_ATTACH_USERNAME, DEFAULT_ATTACH_USERNAME):
user = await self.hass.auth.async_get_user(user_input.context.user_id)
if user is not None and user.name is not None:
user_message[ATTR_NAME] = user.name

messages.append({"role": "user", "content": user_input.text})
messages.append(user_message)

try:
response = await self.query(user_input, messages, exposed_entities, 0)
except error.OpenAIError as err:
_LOGGER.error(err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
Expand All @@ -164,14 +176,8 @@ async def async_process(
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)
except (
EntityNotFound,
ServiceNotFound,
CallServiceError,
EntityNotExposed,
FunctionNotFound,
NativeNotFound,
) as err:
except HomeAssistantError as err:
_LOGGER.error(err, exc_info=err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
Expand Down Expand Up @@ -232,12 +238,17 @@ def get_functions(self):
result = yaml.safe_load(function) if function else DEFAULT_CONF_FUNCTIONS
if result:
for setting in result:
for function in setting["function"].values():
convert_to_template(function, hass=self.hass)
function_executor = get_function_executor(
setting["function"]["type"]
)
setting["function"] = function_executor.to_arguments(
setting["function"]
)
return result
except Exception as e:
_LOGGER.error("Failed to load functions", e)
return []
except (InvalidFunction, FunctionNotFound) as e:
raise e
except:
raise FunctionLoadFailed()

async def query(
self,
Expand All @@ -258,6 +269,7 @@ async def query(
DEFAULT_MAX_FUNCTION_CALLS_PER_CONVERSATION,
):
function_call = "none"
response_format = {"type": "text"}

_LOGGER.info("Prompt for %s: %s", model, messages)

Expand All @@ -272,6 +284,7 @@ async def query(
user=user_input.conversation_id,
functions=functions,
function_call=function_call,
response_format=response_format,
)

_LOGGER.info("Response %s", response)
Expand Down Expand Up @@ -315,8 +328,12 @@ async def execute_function(
n_requests,
function,
):
function_executor = FUNCTION_EXECUTORS[function["function"]["type"]]
arguments = json.loads(message["function_call"]["arguments"])
function_executor = get_function_executor(function["function"]["type"])

try:
arguments = json.loads(message["function_call"]["arguments"])
except json.decoder.JSONDecodeError as err:
raise ParseArgumentsFailed(message["function_call"]["arguments"]) from err

result = await function_executor.execute(
self.hass, function["function"], arguments, user_input, exposed_entities
Expand Down
11 changes: 11 additions & 0 deletions custom_components/extended_openai_conversation/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
NumberSelectorConfig,
TemplateSelector,
Expand All @@ -24,6 +25,7 @@
from .helpers import validate_authentication

from .const import (
CONF_ATTACH_USERNAME,
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_PROMPT,
Expand All @@ -32,6 +34,7 @@
CONF_MAX_FUNCTION_CALLS_PER_CONVERSATION,
CONF_FUNCTIONS,
CONF_BASE_URL,
DEFAULT_ATTACH_USERNAME,
DEFAULT_CHAT_MODEL,
DEFAULT_MAX_TOKENS,
DEFAULT_PROMPT,
Expand Down Expand Up @@ -65,6 +68,7 @@
CONF_TOP_P: DEFAULT_TOP_P,
CONF_TEMPERATURE: DEFAULT_TEMPERATURE,
CONF_FUNCTIONS: DEFAULT_CONF_FUNCTIONS_STR,
CONF_ATTACH_USERNAME: DEFAULT_ATTACH_USERNAME,
}
)

Expand Down Expand Up @@ -194,4 +198,11 @@ def openai_config_option_schema(options: MappingProxyType[str, Any]) -> dict:
description={"suggested_value": options.get(CONF_FUNCTIONS)},
default=DEFAULT_CONF_FUNCTIONS_STR,
): TemplateSelector(),
vol.Optional(
CONF_ATTACH_USERNAME,
description={
"suggested_value": options.get(CONF_ATTACH_USERNAME)
},
default=DEFAULT_ATTACH_USERNAME,
): BooleanSelector(),
}
2 changes: 2 additions & 0 deletions custom_components/extended_openai_conversation/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,5 @@
]
CONF_BASE_URL = "base_url"
DEFAULT_CONF_BASE_URL = "https://api.openai.com/v1"
CONF_ATTACH_USERNAME = "attach_username"
DEFAULT_ATTACH_USERNAME = False
47 changes: 47 additions & 0 deletions custom_components/extended_openai_conversation/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,50 @@ def __init__(self, name: str) -> None:
def __str__(self) -> str:
"""Return string representation."""
return f"native function '{self.name}' does not exist"


class FunctionLoadFailed(HomeAssistantError):
"""When function load failed."""

def __init__(self) -> None:
"""Initialize error."""
super().__init__(
self,
"failed to load functions. Verify functions are valid in a yaml format",
)

def __str__(self) -> str:
"""Return string representation."""
return "failed to load functions. Verify functions are valid in a yaml format"


class ParseArgumentsFailed(HomeAssistantError):
"""When parse arguments failed."""

def __init__(self, arguments: str) -> None:
"""Initialize error."""
super().__init__(
self,
f"failed to parse arguments `{arguments}`. Increase maximum token to avoid the issue.",
)
self.arguments = arguments

def __str__(self) -> str:
"""Return string representation."""
return f"failed to parse arguments `{self.arguments}`. Increase maximum token to avoid the issue."


class InvalidFunction(HomeAssistantError):
"""When function validation failed."""

def __init__(self, function_name: str) -> None:
"""Initialize error."""
super().__init__(
self,
f"failed to validate function `{function_name}`",
)
self.function_name = function_name

def __str__(self) -> str:
"""Return string representation."""
return f"failed to validate function `{self.function_name}` ({self.__cause__})"
Loading

0 comments on commit f858745

Please sign in to comment.