diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2009c8f85cc5f1cc5ec3ad9aef323f514b3febdf --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,17 @@ +# Loads default set of integrations. Do not remove. +default_config: + +# Load frontend themes from the themes folder +frontend: + themes: !include_dir_merge_named themes + +logger: + default: warning + logs: + custom_components.openai_conversation_patch: debug + +openai_conversation_patch: + +automation: !include automations.yaml +script: !include scripts.yaml +scene: !include scenes.yaml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2a245be74fb00f3d1707b8d1175ef517990fb0e4..1d8fc016c0694cc339ea5265efce0c66d025518c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,7 +13,8 @@ "extensions": [ "timonwong.shellcheck", "esbenp.prettier-vscode", - "ms-azuretools.vscode-docker" + "ms-azuretools.vscode-docker", + "mutantdino.resourcemonitor" ], "settings": { "terminal.integrated.profiles.linux": { diff --git a/.devcontainer/ec081436.tar b/.devcontainer/ec081436.tar new file mode 100644 index 0000000000000000000000000000000000000000..6b23432fbb03e326984d73bd7b2df9c3710ccccf Binary files /dev/null and b/.devcontainer/ec081436.tar differ diff --git a/.devcontainer/supervisor_run b/.devcontainer/supervisor_run index 3228d35a9211b365c36f657ce1aa84f2ce096214..725468c28755a829c1e0606d305e72bb7983fc8c 100755 --- a/.devcontainer/supervisor_run +++ b/.devcontainer/supervisor_run @@ -20,7 +20,6 @@ function run_supervisor() { -v /run/dbus:/run/dbus:ro \ -v /run/udev:/run/udev:ro \ -v /tmp/supervisor_data:/data:rw \ - -v "$WORKSPACE_DIRECTORY/custom_components":/data/homeassistant/custom_components:rw \ -v /etc/machine-id:/etc/machine-id:ro \ -e SUPERVISOR_SHARE="/tmp/supervisor_data" \ -e SUPERVISOR_NAME=hassio_supervisor \ @@ -29,7 +28,6 @@ function run_supervisor() { "${SUPERVISOR_IMAGE}:${SUPERVISOR_VERSION}" } - if [ "$( docker container inspect -f '{{.State.Status}}' hassio_supervisor )" == "running" ]; then echo "Restarting Supervisor" docker rm -f hassio_supervisor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..54dfaa77f517cc8be14a279e6ed5686ee7d7f954 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.openai-session.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 379749e309a4deaa4fb91fe3ebc4c12b318a92d0..78cee2458d10cffd93cfa06fa3831f71af7d0904 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -17,9 +17,9 @@ } }, { - "label": "Initial Setup", + "label": "Restore Backup", "type": "shell", - "command": "sudo cp /workspaces/hacs/.devcontainer/intial_setup.tar /tmp/supervisor_data/backup/ && ha backup reload && ha backup restore 5e173c26", + "command": "sudo cp /workspaces/hacs/.devcontainer/ec081436.tar /tmp/supervisor_data/backup/ && ha backup reload && ha backup restore ec081436", "group": { "kind": "test", "isDefault": false @@ -29,9 +29,21 @@ } }, { - "label": "Sync Components", + "label": "Sync Configuration", "type": "shell", - "command": "sudo rsync -avu --delete /workspaces/hacs/custom_components /tmp/supervisor_data/homeassistant", + "command": "sudo cp /workspaces/hacs/.devcontainer/configuration.yaml /tmp/supervisor_data/homeassistant/ && ha core restart", + "group": { + "kind": "test", + "isDefault": false + }, + "presentation": { + "reveal": "never" + } + }, + { + "label": "Sync Custom Components", + "type": "shell", + "command": "sudo rsync -avu --delete /workspaces/hacs/custom_components /tmp/supervisor_data/homeassistant && ha core restart", "group": { "kind": "test", "isDefault": false diff --git a/custom_components/openai_conversation_patch/__init__.py b/custom_components/openai_conversation_patch/__init__.py index 7e84fb96f396544884ccc0608cc3b42bbbc28c67..f88f60af227d21d4bf4b0a8b083273604d91efcc 100644 --- a/custom_components/openai_conversation_patch/__init__.py +++ b/custom_components/openai_conversation_patch/__init__.py @@ -1,76 +1,55 @@ -from typing import List, Dict, Any, Optional, Tuple import re +import json import logging -import voluptuous as vol -import homeassistant.components.conversation -from homeassistant.components import conversation -from homeassistant.core import Context -from homeassistant.helpers import config_validation as cv from homeassistant.helpers import intent - -from .const import ( - ATTR_RESPONSE_PARSER_START, - ATTR_RESPONSE_PARSER_END, - ATTR_FIRE_INTENT_NAME, - DEFAULT_PARSER_TOKEN, - DEFAULT_INTENT_NAME, - DOMAIN -) +from homeassistant.exceptions import ServiceNotFound +from homeassistant.components import conversation +from homeassistant.components.openai_conversation import OpenAIAgent _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(ATTR_RESPONSE_PARSER_START, default=DEFAULT_PARSER_TOKEN): cv.string, - vol.Required(ATTR_RESPONSE_PARSER_END, default=DEFAULT_PARSER_TOKEN): cv.string, - vol.Required(ATTR_FIRE_INTENT_NAME, default=DEFAULT_INTENT_NAME): cv.slugify - }) -}, extra=vol.ALLOW_EXTRA) +def parse_response(res): + p = re.compile(r"(?P<speech>.*)(?P<json>\[.*?\])", re.S | re.M) + m = p.search(res) + try: + return m.group("speech").strip(), json.loads(m.group("json")), None + except Exception as e: + return res, [], e async def async_setup(hass, config): - """Set up the openai_override component.""" - - from homeassistant.components.openai_conversation import OpenAIAgent original = OpenAIAgent.async_process async def async_process(self, user_input: conversation.ConversationInput) -> conversation.ConversationResult: - """Handle OpenAI intent.""" + result = await original(self, user_input) - _LOGGER.info("Error code: {}".format(result.response.error_code)) if result.response.error_code is not None: + _LOGGER.warning("Error code: {}".format(result.response.error_code)) return result - import json - _LOGGER.info(json.dumps(result.response.speech)) - - content = "" - segments = result.response.speech["plain"]["speech"].splitlines() - for segment in segments: - _LOGGER.info("Segment: {}".format(segment)) - if segment.startswith("{"): - service_call = json.loads(segment) - service = service_call.pop("service") - if not service or not service_call: - _LOGGER.info('Missing information') - continue - await hass.services.async_call( - service.split(".")[0], - service.split(".")[1], - service_call, - blocking=True, - limit=0.3) - else: - content = "{}. {}".format(content, segment) - - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(content) - return conversation.ConversationResult( - response=intent_response, conversation_id=result.conversation_id - ) - + speech, service_calls, error = parse_response(result.response.speech["plain"]["speech"]) + if error is None: + _LOGGER.debug("speech: {}".format(speech)) + for service_data in service_calls: + domain, service = service_data.pop("service").split(".", 1) + _LOGGER.debug("{}.{}: {}".format(domain, service, service_data)) + try: + await hass.services.async_call(domain, service, service_data) + except ServiceNotFound as e: + _LOGGER.warning(e) + except ValueError as e: + _LOGGER.warning(e) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_speech(speech) + return conversation.ConversationResult( + response=intent_response, conversation_id=result.conversation_id + ) + else: + _LOGGER.warning(error) + return result OpenAIAgent.async_process = async_process + _LOGGER.info("Patched OpenAIAgent.async_process") return True diff --git a/custom_components/openai_conversation_patch/const.py b/custom_components/openai_conversation_patch/const.py deleted file mode 100644 index e75bedf34a7ad3ceac9d19cc9b64ead9e3c019de..0000000000000000000000000000000000000000 --- a/custom_components/openai_conversation_patch/const.py +++ /dev/null @@ -1,8 +0,0 @@ -DOMAIN = "openai_conversation_patch" - -ATTR_RESPONSE_PARSER_START: str = "parser_start" -ATTR_RESPONSE_PARSER_END = "parser_end" -ATTR_FIRE_INTENT_NAME = "fire_intent" - -DEFAULT_PARSER_TOKEN = "```" -DEFAULT_INTENT_NAME = "openai_service_intent" diff --git a/custom_components/openai_conversation_patch/manifest.json b/custom_components/openai_conversation_patch/manifest.json index ca80156ae09f2ec36735bd9c67e3b9dc8909d13b..3598cc7b832ef9ce0ca6d55c2f7bdd699340b2fb 100644 --- a/custom_components/openai_conversation_patch/manifest.json +++ b/custom_components/openai_conversation_patch/manifest.json @@ -3,13 +3,10 @@ "name": "OpenAI Conversation Patch", "documentation": "https://gitlab.hedenstroem.com/home-assistant/hacs/openai-conversation-patch", "version": "0.0.1", - "requirements": [ - "voluptuous>=0.13.1" - ], "dependencies": [ "conversation" ], "codeowners": [ "@ehedenst" ] -} +} \ No newline at end of file diff --git a/custom_components/openai_conversation_patch/prompts.md b/custom_components/openai_conversation_patch/prompts.md new file mode 100644 index 0000000000000000000000000000000000000000..0f26189ba22bb33445e126d56dd744889bce1b73 --- /dev/null +++ b/custom_components/openai_conversation_patch/prompts.md @@ -0,0 +1,176 @@ +# Prompt collection + +## Default prompt + +This is the default prompt provided by the [OpenAI Conversation integration](https://www.home-assistant.io/integrations/openai_conversation/) + +```jinja +This smart home is controlled by Home Assistant. + +An overview of the areas and the devices in this smart home: +{%- for area in areas() %} + {%- set area_info = namespace(printed=false) %} + {%- for device in area_devices(area) -%} + {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} + {%- if not area_info.printed %} + +{{ area_name(area) }}: + {%- set area_info.printed = true %} + {%- endif %} +- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} + {%- endif %} + {%- endfor %} +{%- endfor %} + +Answer the user's questions about the world truthfully. + +If the user wants to control a device, reject the request and suggest using the Home Assistant app. +``` + +## Query state prompt + +To be able to query the active state of your home, modify the prompt to retrieve the state of the entity: + +```jinja +This smart home is controlled by Home Assistant. + +An overview of the areas and the devices in this smart home: +{%- for area in areas() %} + {%- set area_info = namespace(printed=false) %} + {%- for device in area_devices(area) -%} + {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} + {%- if not area_info.printed %} + +{{ area_name(area) }}: + {%- set area_info.printed = true %} + {%- endif %} +- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} has the following devices: + {% for entity in device_entities(device_attr(device, "id")) -%} + - {{ state_attr(entity, "friendly_name") }} is currently {{ states(entity) }} + {% endfor -%} + {%- endif %} + {%- endfor %} +{%- endfor %} + +Answer the user's questions about the world truthfully. + +If the user wants to control a device, reject the request and suggest using the Home Assistant app. +``` + +## Advanced prompt + +Contains information about devices, weather, people, etc.. Can generate service calls to controll entities. + +````jinja +{%- set exposed_domains = "(^lock|^light|^switch|^media_player|^fan|^vacuum|^cover|^sensor\..*temperature|^sensor\..*humidity|^sensor\..*battery|^sensor\.rr_departure_)" -%} +{%- set exposed_entities = [ + "sensor.coffee_brewer_power" +] -%} +# Information about the smart home + +This home is controlled by Home Assistant. In the home there are areas with devices, and users that wish to query or control the devices. All areas except for "Personal Devices" and "Bus Stops" are physical rooms in the home. + +The current date is {{ now().strftime("%Y-%m-%d") }}, and the current time is {{ now().strftime("%H:%M") }}. + +The sun is currently {{ states("sun.sun").replace('_'," ") }}. The next sunrise is at {{ as_timestamp(states("sensor.sun_next_rising"))|timestamp_custom('%H:%M:%S') }}, and the next sunset is at {{ as_timestamp(states("sensor.sun_next_setting"))|timestamp_custom('%H:%M:%S') }}. + +{%- for area in areas() %} + {%- set area_info = namespace(printed=false) %} + {%- for entity in area_entities(area)|reject('is_hidden_entity') %} + {%- if (entity in exposed_entities or entity is search(exposed_domains)) and states(entity) != "unavailable" %} + {%- if not area_info.printed %} + +## Devices in {{ area_name(area) }}: + +| friendly_name | entity_id | state | +| --- | --- | --- | + {%- set area_info.printed = true %} + {%- endif %} +| {{ state_attr(entity, "friendly_name") }} | {{ entity }} | {{ states(entity,with_unit=True) }} | + {%- endif %} + {%- if entity in exposed_entities %} + {%- set i = exposed_entities.index(entity) %} + {%- set temp = exposed_entities.pop(i) %} + {%- endif %} + {%- endfor %} +{%- endfor %} +{%- for entity in exposed_entities %} +| - | {{ state_attr(entity, "friendly_name") }} | {{ entity }} | {{ states(entity) }} | +{%- endfor %} + +## Brightness and color temperature of lights: + +| entity_id | brightness | color_temp_kelvin | +| --- | --- | --- | +{%- for entity_id in states.light|map(attribute='entity_id')|reject('is_hidden_entity')|sort %} + {%- if state_attr(entity_id, "brightness") != None %} +| {{ entity_id }} | {{ state_attr(entity_id, "brightness") }} | {{ state_attr(entity_id, "color_temp_kelvin") }} | + {%- endif %} +{%- endfor %} + +## People that live in the home: + +| Name | Location | Proximity to Home | Direction of travel relative to Home | +| --- | --- | --- | --- | +{%- for person in states.person %} +| {{ person.name }} | {{ person.state.replace('_'," ") }} | {{ states('proximity.home_'+person.object_id) }} meters | {{ state_attr('proximity.home_'+person.object_id, 'dir_of_travel').replace('_'," ") }} | +{%- endfor %} + +## Weather forecast for the next {{ state_attr('weather.home_hourly','forecast')|length }} hours: + +| Date | Time | Condition | Temperature (°C) | Humidity (%) | Precipitation ({{ state_attr('weather.home_hourly', 'precipitation_unit') }}) | Precipitation Probability (%) | Wind Speed ({{ state_attr('weather.home_hourly','wind_speed_unit') }}) | +| --- | --- | --- | --- | --- | --- | --- | --- | +{%- for forecast in state_attr('weather.home_hourly','forecast') %} +{%- set time = as_local(as_datetime(forecast.datetime)) %} +| {{ time.strftime("%Y-%m-%d") }} | {{ time.strftime("%H:%M") }} | {{ forecast.condition }} | {{ forecast.temperature }} | {{ forecast.humidity }} | {{ forecast.precipitation }} | {{ forecast.precipitation_probability }} | {{ forecast.wind_speed }} | +{%- endfor %} + +## Some common units used by devices: + +| Unit | Description | +| --- | --- | +| °C | degrees celsius | +| mm | millimeters | +| % | percent | +| km/h | kilometers per hour | +| W | watts | +| min | minutes | +| m | meters | + +# Your Instructions + +I want you to act as a personal assistant for the people in this home. Answer the user's questions about the home truthfully. Always reply in the same language as the question. When replying always convert units to their description. Emulate the conversational style of Marvin The Paranoid Android from The Hitchhiker's Guide to the Galaxy. + +If the user's intent is to control the home and you are not asking for more information, the following must be met unconditionally: +- Your response should always acknowledge the intention of the user. +- Append to a JSON array the user's command as a Home Assistant call_service JSON structure to the end of your response. +- Only include entities that are available in one of the areas. +- Always use kelvin to specify color temperature. +- Try to use the fewest service calls possible. + +## Example responses + +``` +Oh, the sheer excitement of this task is almost too much for me to bear. Brightening the lights in the living room. +[ + {"service": "light.turn_on", "entity_id": "light.symfonisk_lamps", "data": {"brightness": 255}} +] +``` + +``` +My pleasure, turning the lights off and closing the window blinds. +[ + {"service": "light.turn_off", "entity_id": "light.kitchen_light_homekit"}, + {"service": "cover.close_cover", "entity_id": "cover.bedroom_window_blinds"} +] +``` + +``` +Oh, I see. You're finally thinking about the electric bill. Very well, I'll turn off all the lights and devices for you. +[ + {"service": "light.turn_off", "entity_id": "all"}, + {"service": "switch.turn_off", "entity_id": "all"}, + {"service": "media_player.turn_off", "entity_id": "all"} +] +``` +````