From 7cd49c70a38edd65ee495fbfc455dc42a20c324c Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Thu, 16 Feb 2023 17:05:14 -0800 Subject: [PATCH 01/40] Update automation to shared repository (#15) * Update automation to shared repository Add license test automation * Update neon-utils dependency extras --- .github/workflows/license_tests.yml | 8 +++++ .github/workflows/publish_release.yml | 42 +++++++----------------- .github/workflows/publish_test_build.yml | 33 +++---------------- .github/workflows/pull_master.yml | 15 +++------ .github/workflows/unit_tests.yml | 16 ++------- requirements/requirements.txt | 3 +- 6 files changed, 31 insertions(+), 86 deletions(-) create mode 100644 .github/workflows/license_tests.yml diff --git a/.github/workflows/license_tests.yml b/.github/workflows/license_tests.yml new file mode 100644 index 0000000..a284db6 --- /dev/null +++ b/.github/workflows/license_tests.yml @@ -0,0 +1,8 @@ +name: Run License Tests +on: + push: + workflow_dispatch: + +jobs: + license_tests: + uses: neongeckocom/.github/.github/workflows/license_tests.yml@master diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index 65ee102..e34a096 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -7,34 +7,14 @@ on: - master jobs: - tag_release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Get Version - run: | - VERSION=$(python setup.py --version) - echo "VERSION=${VERSION}" >> $GITHUB_ENV - - uses: ncipollo/release-action@v1 - with: - token: ${{secrets.GITHUB_TOKEN}} - tag: ${{env.VERSION}} - build_and_publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - name: Install Build Tools - run: | - python -m pip install build wheel - - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} \ No newline at end of file + build_and_publish_pypi_and_release: + uses: neongeckocom/.github/.github/workflows/publish_stable_release.yml@master + secrets: inherit + build_and_publish_docker: + needs: build_and_publish_pypi_and_release + uses: neongeckocom/.github/.github/workflows/publish_docker.yml@master + secrets: inherit + with: + image_name: ${{ github.repository_owner }}/neon_skills + base_tag: base + extra_tag: default_skills diff --git a/.github/workflows/publish_test_build.yml b/.github/workflows/publish_test_build.yml index c53fcab..893a4a1 100644 --- a/.github/workflows/publish_test_build.yml +++ b/.github/workflows/publish_test_build.yml @@ -6,34 +6,9 @@ on: branches: - dev paths-ignore: - - 'neon_iris/version.py' + - 'version.py' jobs: - build_and_publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - ref: ${{ github.head_ref }} - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: Increment Version - run: | - VER=$(python setup.py --version) - python version_bump.py - - name: Push Version Change - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Increment Version - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} \ No newline at end of file + build_and_publish_pypi: + uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@master + secrets: inherit diff --git a/.github/workflows/pull_master.yml b/.github/workflows/pull_master.yml index 8e9c5a8..8ab60d3 100644 --- a/.github/workflows/pull_master.yml +++ b/.github/workflows/pull_master.yml @@ -5,17 +5,10 @@ on: push: branches: - dev - workflow_dispatch: jobs: pull_changes: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: pull-request-action - uses: repo-sync/pull-request@v2 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - pr_reviewer: 'neonreviewers' - pr_assignee: 'neondaniel' - pr_draft: true \ No newline at end of file + uses: neongeckocom/.github/.github/workflows/pull_master.yml@master + with: + pr_reviewer: neonreviewers + pr_assignee: neondaniel diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index b7d0f41..69e957a 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -6,20 +6,8 @@ on: workflow_dispatch: jobs: - build_tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel + py_build_tests: + uses: neongeckocom/.github/.github/workflows/python_build_tests.yml@master unit_tests: strategy: matrix: diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 2c64f5f..ec28b18 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,4 +1,5 @@ click~=8.0 click-default-group~=1.2 -neon_utils~=1.0 +neon-utils~=1.0 pyyaml~=5.4 +neon-mq-connector~=0.6 From eb0c9abd12656e67d0ab86065df45b2294f01679 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Fri, 17 Feb 2023 01:05:55 +0000 Subject: [PATCH 02/40] Increment Version --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index cf52b5b..35115dd 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.4" +__version__ = "0.0.5a0" From 3d874266eac2122ae0e91f2812ce88759039e271 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Thu, 16 Feb 2023 18:48:00 -0800 Subject: [PATCH 03/40] Remove invalid release Docker action (#17) --- .github/workflows/publish_release.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index e34a096..e4b3fa3 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -10,11 +10,3 @@ jobs: build_and_publish_pypi_and_release: uses: neongeckocom/.github/.github/workflows/publish_stable_release.yml@master secrets: inherit - build_and_publish_docker: - needs: build_and_publish_pypi_and_release - uses: neongeckocom/.github/.github/workflows/publish_docker.yml@master - secrets: inherit - with: - image_name: ${{ github.repository_owner }}/neon_skills - base_tag: base - extra_tag: default_skills From 6c9b78a95f0cfdf4c626b1d1ff91e5ce23e888a0 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Fri, 17 Feb 2023 02:48:50 +0000 Subject: [PATCH 04/40] Increment Version --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 35115dd..fb40abb 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a0" +__version__ = "0.0.5a1" From b16399c35b8be94023d758ecb4d0e4a7930508ec Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Wed, 22 Feb 2023 16:34:36 -0800 Subject: [PATCH 05/40] Update to use shared version_bump.py script (#18) --- .github/workflows/publish_test_build.yml | 6 ++- setup.py | 9 ++-- version_bump.py | 56 ------------------------ 3 files changed, 10 insertions(+), 61 deletions(-) delete mode 100644 version_bump.py diff --git a/.github/workflows/publish_test_build.yml b/.github/workflows/publish_test_build.yml index 893a4a1..80cc143 100644 --- a/.github/workflows/publish_test_build.yml +++ b/.github/workflows/publish_test_build.yml @@ -6,9 +6,11 @@ on: branches: - dev paths-ignore: - - 'version.py' + - 'neon_iris/version.py' jobs: build_and_publish_pypi: - uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@master + uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@FEAT_SharedScripts secrets: inherit + with: + version_file: "neon_iris/version.py" diff --git a/setup.py b/setup.py index 540638d..1a20b6a 100644 --- a/setup.py +++ b/setup.py @@ -31,18 +31,21 @@ from os import path +BASE_PATH = path.abspath(path.dirname(__file__)) + + def get_requirements(requirements_filename: str): - requirements_file = path.join(path.abspath(path.dirname(__file__)), "requirements", requirements_filename) + requirements_file = path.join(BASE_PATH, "requirements", requirements_filename) with open(requirements_file, 'r', encoding='utf-8') as r: requirements = r.readlines() requirements = [r.strip() for r in requirements if r.strip() and not r.strip().startswith("#")] return requirements -with open("README.md", "r") as f: +with open(path.join(BASE_PATH, "README.md"), "r") as f: long_description = f.read() -with open("neon_iris/version.py", "r", encoding="utf-8") as v: +with open(path.join(BASE_PATH, "neon_iris", "version.py"), "r", encoding="utf-8") as v: for line in v.readlines(): if line.startswith("__version__"): if '"' in line: diff --git a/version_bump.py b/version_bump.py deleted file mode 100644 index 9ea1a80..0000000 --- a/version_bump.py +++ /dev/null @@ -1,56 +0,0 @@ -# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework -# All trademark and other rights reserved by their respective owners -# Copyright 2008-2022 Neongecko.com Inc. -# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, -# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo -# BSD-3 License -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from this -# software without specific prior written permission. -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, -# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, -# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -import fileinput -from os.path import join, dirname - -with open(join(dirname(__file__), "neon_iris", "version.py"), - "r", encoding="utf-8") as v: - for line in v.readlines(): - if line.startswith("__version__"): - if '"' in line: - version = line.split('"')[1] - else: - version = line.split("'")[1] - -if "a" not in version: - parts = version.split('.') - parts[-1] = str(int(parts[-1]) + 1) - version = '.'.join(parts) - version = f"{version}a0" -else: - post = version.split("a")[1] - new_post = int(post) + 1 - version = version.replace(f"a{post}", f"a{new_post}") - -for line in fileinput.input(join(dirname(__file__), "neon_iris", - "version.py"), inplace=True): - if line.startswith("__version__"): - print(f"__version__ = \"{version}\"") - else: - print(line.rstrip('\n')) From 283a4c34d6d01d56b5239f7e9fff443ebfcef3e4 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Wed, 22 Feb 2023 17:00:10 -0800 Subject: [PATCH 06/40] Update build automation to default branch (#19) --- .github/workflows/publish_test_build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_test_build.yml b/.github/workflows/publish_test_build.yml index 80cc143..7db566b 100644 --- a/.github/workflows/publish_test_build.yml +++ b/.github/workflows/publish_test_build.yml @@ -10,7 +10,7 @@ on: jobs: build_and_publish_pypi: - uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@FEAT_SharedScripts + uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@master secrets: inherit with: version_file: "neon_iris/version.py" From 4b62743721b33969778e09a80a527d79bac8902c Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Wed, 22 Feb 2023 18:00:36 -0800 Subject: [PATCH 07/40] Increment Version Patching automation error --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index fb40abb..88a9056 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a1" +__version__ = "0.0.5a2" From c25bce9e2d46453e86b885173d7963460f55ff14 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Wed, 22 Feb 2023 18:06:52 -0800 Subject: [PATCH 08/40] Specify `setup.py` path explicitly (#20) --- .github/workflows/publish_test_build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish_test_build.yml b/.github/workflows/publish_test_build.yml index 7db566b..e1240fa 100644 --- a/.github/workflows/publish_test_build.yml +++ b/.github/workflows/publish_test_build.yml @@ -14,3 +14,4 @@ jobs: secrets: inherit with: version_file: "neon_iris/version.py" + setup_py: "setup.py" From 337f5f63494b225e747fb7b7e89c2397675752bd Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Thu, 23 Feb 2023 02:07:45 +0000 Subject: [PATCH 09/40] Increment Version --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 88a9056..d3f9857 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a2" +__version__ = "0.0.5a3" From dee278bc352018e75e9fdeec24f77a2d424d36ef Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Tue, 25 Jul 2023 16:47:01 -0700 Subject: [PATCH 10/40] Add CLI endpoints to interact with API and LLM endpoints (#21) * Add CLI endpoints to interact with API and LLM endpoints * Add longer timeout for LLM CLI Update messagebus dependency to ovos-bus-client * Fix config handling bug * Add option for weather CLI to select an endpoint * Add options to LLM help text * Print MQ server config when using CLI entrypoints * Annotate missing CLI methods * Implement coupons util Implement script util Update docstrings * Loosen pyyaml dependency to work around https://github.com/yaml/pyyaml/issues/724 --- README.md | 22 ++++++ neon_iris/cli.py | 130 +++++++++++++++++++++++++++++----- neon_iris/client.py | 5 +- neon_iris/llm.py | 45 ++++++++++++ neon_iris/util.py | 92 ++++++++++++++++++++++++ requirements/requirements.txt | 5 +- tests/test_client.py | 4 +- 7 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 neon_iris/llm.py create mode 100644 neon_iris/util.py diff --git a/README.md b/README.md index fa8e145..b2c7eb8 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,25 @@ interacting with Neon systems remotely, via [MQ](https://github.com/NeonGeckoCom Install the Iris Python package with: `pip install neon-iris` The `iris` entrypoint is available to interact with a bus via CLI. Help is available via `iris --help`. + + +## Debugging a Diana installation +The `iris` CLI includes utilities for interacting with a `Diana` backend. + +### Configuration +Configuration files can be specified via environment variables. By default, +`Iris` will set default values: +``` +OVOS_CONFIG_BASE_FOLDER=neon +OVOS_CONFIG_FILENAME=diana.yaml +``` + +The example below would override defaults to read configuration from +`~/.config/mycroft/mycroft.conf`. +``` +export OVOS_CONFIG_BASE_FOLDER=mycroft +export OVOS_CONFIG_FILENAME=mycroft.conf +``` + +More information about configuration handling can be found +[in the docs](https://neongeckocom.github.io/neon-docs/quick_reference/configuration/). \ No newline at end of file diff --git a/neon_iris/cli.py b/neon_iris/cli.py index be4b248..6827432 100644 --- a/neon_iris/cli.py +++ b/neon_iris/cli.py @@ -24,19 +24,30 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import json import logging +from pprint import pformat + import click -import yaml +from os import environ from os.path import expanduser, isfile from time import sleep from click_default_group import DefaultGroup +from ovos_utils.log import LOG -from neon_utils.logger import LOG -from neon_iris.client import CLIClient +from neon_iris.util import load_config_file from neon_iris.version import __version__ +environ.setdefault("OVOS_CONFIG_BASE_FOLDER", "neon") +environ.setdefault("OVOS_CONFIG_FILENAME", "diana.yaml") + + +def _print_config(): + from ovos_config.config import Configuration + config = Configuration().get('MQ') + mq_endpoint = f"{config.get('server')}:{config.get('port', 5672)}" + click.echo(f"Connecting to {mq_endpoint}") + @click.group("iris", cls=DefaultGroup, no_args_is_help=True, invoke_without_command=True, @@ -59,19 +70,11 @@ def neon_iris_cli(version: bool = False): @click.option('--audio', '-a', is_flag=True, default=False, help="Flag to enable audio playback") def start_client(mq_config, user_config, lang, audio): + from neon_iris.client import CLIClient if mq_config: - with open(mq_config) as f: - try: - mq_config = json.load(f) - except Exception as e: - f.seek(0) - mq_config = yaml.safe_load(f) + mq_config = load_config_file(expanduser(mq_config)) if user_config: - with open(user_config) as f: - try: - user_config = json.load(f) - except Exception as e: - user_config = None + user_config = load_config_file(expanduser(user_config)) client = CLIClient(mq_config, user_config) LOG.init({"level": logging.WARNING}) @@ -111,5 +114,98 @@ def start_client(mq_config, user_config, lang, audio): client.shutdown() -if __name__ == "__main__": - start_client(None, None, "en-us") +@neon_iris_cli.command(help="Query a weather endpoint") +@click.option('--unit', '-u', default='imperial', + help="units to use ('metric' or 'imperial')") +@click.option('--latitude', '--lat', default=47.6815, + help="location latitude") +@click.option('--longitude', '--lon', default=-122.2087, + help="location latitude") +@click.option('--api', '-a', default='onecall', + help="api to query ('onecall' or 'weather')") +def get_weather(unit, latitude, longitude, api): + from neon_iris.util import query_api + _print_config() + query = {"lat": latitude, + "lon": longitude, + "units": unit, + "api": api, + "service": "open_weather_map"} + resp = query_api(query) + click.echo(pformat(resp)) + + +@neon_iris_cli.command(help="Query a stock price endpoint") +@click.argument('symbol') +def get_stock_quote(symbol): + from neon_iris.util import query_api + _print_config() + query = {"symbol": symbol, + "api": "quote", + "service": "alpha_vantage"} + resp = query_api(query) + click.echo(pformat(resp)) + + +@neon_iris_cli.command(help="Query a stock symbol endpoint") +@click.argument('company') +def get_stock_symbol(company): + from neon_iris.util import query_api + _print_config() + query = {"company": company, + "api": "symbol", + "service": "alpha_vantage"} + resp = query_api(query) + click.echo(pformat(resp)) + + +@neon_iris_cli.command(help="Query a WolframAlpha endpoint") +@click.option('--api', '-a', default='short', + help="Wolfram|Alpha API to query") +@click.option('--unit', '-u', default='imperial', + help="units to use ('metric' or 'imperial')") +@click.option('--latitude', '--lat', default=47.6815, + help="location latitude") +@click.option('--longitude', '--lon', default=-122.2087, + help="location latitude") +@click.argument('question') +def get_wolfram_response(api, unit, latitude, longitude, question): + from neon_iris.util import query_api + _print_config() + query = {"api": api, + "units": unit, + "latlong": f"{latitude},{longitude}", + "query": question, + "service": "wolfram_alpha"} + resp = query_api(query) + click.echo(pformat(resp)) + + +@neon_iris_cli.command(help="Converse with an LLM") +@click.option('--llm', default="chat_gpt", + help="LLM Queue to interact with ('chat_gpt' or 'fastchat')") +def start_llm_chat(llm): + from neon_iris.llm import LLMConversation + _print_config() + conversation = LLMConversation(llm) + while True: + query = click.prompt(">") + resp = conversation.get_response(query) + click.echo(resp) + + +@neon_iris_cli.command(help="Converse with an LLM") +def get_coupons(): + from neon_iris.util import get_brands_coupons + data = get_brands_coupons() + click.echo(pformat(data)) + + +@neon_iris_cli.command(help="Parse a Neon CCL script") +@click.argument("script_file") +def parse_script(script_file): + from neon_iris.util import parse_ccl_script + data = parse_ccl_script(script_file) + click.echo(pformat(data)) + +# TODO: email, metrics diff --git a/neon_iris/client.py b/neon_iris/client.py index fd2f148..0ed3b3b 100644 --- a/neon_iris/client.py +++ b/neon_iris/client.py @@ -37,7 +37,7 @@ from time import time from typing import Optional from uuid import uuid4 -from mycroft_bus_client import Message +from ovos_bus_client.message import Message from pika.exceptions import StreamLostError from neon_utils.configuration_utils import get_neon_user_config from neon_utils.mq_utils import NeonMQHandler @@ -290,7 +290,8 @@ def _send_serialized_message(self, serialized: dict): self.shutdown() def _init_mq_connection(self): - mq_connection = NeonMQHandler(self._config, "mq_handler", self._vhost) + mq_config = self._config.get("MQ") or self._config + mq_connection = NeonMQHandler(mq_config, "mq_handler", self._vhost) mq_connection.register_consumer("neon_response_handler", self._vhost, self.uid, self.handle_neon_response, auto_ack=False) diff --git a/neon_iris/llm.py b/neon_iris/llm.py new file mode 100644 index 0000000..6cb0b65 --- /dev/null +++ b/neon_iris/llm.py @@ -0,0 +1,45 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2022 Neongecko.com Inc. +# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, +# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo +# BSD-3 License +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from neon_mq_connector.utils.client_utils import send_mq_request + + +class LLMConversation: + def __init__(self, llm: str = "chat_gpt"): + self.history = list() + self.queue = f"{llm}_input" + + def get_response(self, query: str): + resp = send_mq_request("/llm", {'query': query, + 'history': self.history}, self.queue, + timeout=90) + reply = resp.get("response") or "" + if reply: + self.history.append(("user", query)) + self.history.append(("llm", reply)) + return reply diff --git a/neon_iris/util.py b/neon_iris/util.py new file mode 100644 index 0000000..3db7e5e --- /dev/null +++ b/neon_iris/util.py @@ -0,0 +1,92 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2021 Neongecko.com Inc. +# BSD-3 +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + +import json +import yaml + +from os.path import isfile +from ovos_utils.log import LOG + + +def load_config_file(file_path: str) -> dict: + """ + Load a config file (json or yaml) and return the dict contents + :param file_path: path to config file to load + """ + if not isfile(file_path): + raise FileNotFoundError(f"Requested config file not found: {file_path}") + with open(file_path) as f: + try: + config = json.load(f) + except Exception as e: + LOG.debug(e) + f.seek(0) + config = yaml.safe_load(f) + return config + + +def query_api(query_params: dict, timeout: int = 10) -> dict: + """ + Query an API service on the `/neon_api` vhost. + :param query_params: dict query to send + :param timeout: seconds to wait for a response + :returns: dict MQ response + """ + from neon_mq_connector.utils.client_utils import send_mq_request + response = send_mq_request("/neon_api", query_params, "neon_api_input", + "neon_api_output", timeout) + return response + + +def get_brands_coupons(timeout: int = 5) -> dict: + """ + Get brands/coupons data on the `/neon_coupons` vhost. + :param timeout: seconds to wait for a response + :returns: dict MQ response + """ + from neon_mq_connector.utils.client_utils import send_mq_request + response = send_mq_request("/neon_coupons", {}, "neon_coupons_input", + "neon_coupons_output", timeout) + return response + + +def parse_ccl_script(script_path: str, metadata: dict = None, + timeout: int = 30) -> dict: + """ + Parse a nct script file into an ncs formatted file + :param script_path: path to file to parse + :param metadata: Optional dict metadata to include in output + :param timeout: seconds to wait for a response + :returns: dict MQ response + """ + from neon_mq_connector.utils.client_utils import send_mq_request + with open(script_path, 'r') as f: + text = f.read() + metadata = metadata or {} + response = send_mq_request("/neon_script_parser", {"text": text, + "metadata": metadata}, + "neon_script_parser_input", + "neon_script_parser_output", timeout) + return response diff --git a/requirements/requirements.txt b/requirements/requirements.txt index ec28b18..1d1b5ba 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,5 +1,6 @@ click~=8.0 click-default-group~=1.2 neon-utils~=1.0 -pyyaml~=5.4 -neon-mq-connector~=0.6 +pyyaml>=5.4,<7.0.0 +neon-mq-connector~=0.7 +ovos-bus-client~=0.0.3 \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index bdfec89..183c4c9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -33,7 +33,7 @@ from neon_utils.mq_utils import NeonMQHandler sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) -from neon_iris.client import NeonAIClient, CLIClient +from neon_iris.client import NeonAIClient _test_config = { "MQ": { @@ -53,6 +53,8 @@ class TestClient(unittest.TestCase): def test_client_create(self): client = NeonAIClient(_test_config) self.assertIsInstance(client.uid, str) + self.assertEqual(client._config, _test_config) + self.assertEqual(client._connection.config, _test_config["MQ"]) self.assertTrue(os.path.isdir(client.audio_cache_dir)) self.assertIsInstance(client.client_name, str) self.assertIsInstance(client.connection, NeonMQHandler) From fd16e40ebbe1d58bc802f7414cea279e529a76a3 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Tue, 25 Jul 2023 23:47:16 +0000 Subject: [PATCH 11/40] Increment Version to 0.0.5a4 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index d3f9857..0f05937 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a3" +__version__ = "0.0.5a4" From c9897b76856c68fe387eb3a23302b5d094c3733a Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Wed, 2 Aug 2023 16:06:19 -0700 Subject: [PATCH 12/40] Add methods and entrypoints to get STT and TTS responses (#12) Update mq connector dependency to include routing fixes --- neon_iris/cli.py | 27 +++++++++++++++++++++++ neon_iris/util.py | 40 +++++++++++++++++++++++++++++++++++ requirements/requirements.txt | 2 +- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/neon_iris/cli.py b/neon_iris/cli.py index 6827432..f26f785 100644 --- a/neon_iris/cli.py +++ b/neon_iris/cli.py @@ -71,8 +71,12 @@ def neon_iris_cli(version: bool = False): help="Flag to enable audio playback") def start_client(mq_config, user_config, lang, audio): from neon_iris.client import CLIClient + _print_config() if mq_config: mq_config = load_config_file(expanduser(mq_config)) + else: + from ovos_config.config import Configuration + mq_config = Configuration().get("MQ") if user_config: user_config = load_config_file(expanduser(user_config)) client = CLIClient(mq_config, user_config) @@ -114,6 +118,29 @@ def start_client(mq_config, user_config, lang, audio): client.shutdown() +@neon_iris_cli.command(help="Transcribe an audio file") +@click.option('--lang', '-l', default='en-us', + help="language of input audio") +@click.argument("audio_file") +def get_stt(audio_file, lang): + from neon_iris.util import get_stt + _print_config() + resp = get_stt(audio_file, lang) + click.echo(pformat(resp)) + + +@neon_iris_cli.command(help="Transcribe an audio file") +@click.option('--lang', '-l', default='en-us', + help="language of input audio") +@click.argument("utterance") +def get_tts(utterance, lang): + from neon_iris.util import get_tts + _print_config() + resp = get_tts(utterance, lang) + click.echo(pformat(resp)) + + +# Backend @neon_iris_cli.command(help="Query a weather endpoint") @click.option('--unit', '-u', default='imperial', help="units to use ('metric' or 'imperial')") diff --git a/neon_iris/util.py b/neon_iris/util.py index 3db7e5e..95f8201 100644 --- a/neon_iris/util.py +++ b/neon_iris/util.py @@ -29,6 +29,8 @@ from os.path import isfile from ovos_utils.log import LOG +from neon_utils.file_utils import encode_file_to_base64_string + def load_config_file(file_path: str) -> dict: """ @@ -90,3 +92,41 @@ def parse_ccl_script(script_path: str, metadata: dict = None, "neon_script_parser_input", "neon_script_parser_output", timeout) return response + + +def query_neon(msg_type: str, data: dict, timeout: int = 10) -> dict: + """ + Query a Neon Core service on the `/neon_chat_api` + :param msg_type: string message type to emit + :param data: message data to send + :param timeout: seconds to wait for a response + """ + from neon_mq_connector.utils.client_utils import send_mq_request + query = {"msg_type": msg_type, "data": data, "context": {"source": "iris"}} + response = send_mq_request("/neon_chat_api", query, "neon_chat_api_request", + timeout=timeout) + if response: + response["context"]["session"] = \ + set(response["context"].pop("session").keys()) + return response + + +def get_stt(audio_file: str, lang: str = "en-us") -> dict: + data = {"audio_file": audio_file, + "audio_data": encode_file_to_base64_string(audio_file), + "utterances": [""], # TODO: For MQ Connector compat. + "lang": lang} + response = query_neon("neon.get_stt", data, 20) + return response + + +def get_tts(string: str, lang: str = "en-us") -> dict: + data = {"text": string, + "utterance": string, # TODO: For MQ Connector compat. + "utterances": [""], # TODO: For MQ Connector compat. + "speaker": {"name": "Neon", + "language": lang, + "gender": "female"}, # TODO: For neon_audio compat. + "lang": lang} + response = query_neon("neon.get_tts", data) + return response diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 1d1b5ba..d41d9c0 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -2,5 +2,5 @@ click~=8.0 click-default-group~=1.2 neon-utils~=1.0 pyyaml>=5.4,<7.0.0 -neon-mq-connector~=0.7 +neon-mq-connector~=0.7,>=0.7.1a3 ovos-bus-client~=0.0.3 \ No newline at end of file From 87fc013ac537483562b02208deafe57b06369c34 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Wed, 2 Aug 2023 23:06:38 +0000 Subject: [PATCH 13/40] Increment Version to 0.0.5a5 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 0f05937..a9d6c0e 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a4" +__version__ = "0.0.5a5" From 46ff16ce402c1d1d6c44a7e2edd1004c1bf8929f Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Fri, 11 Aug 2023 13:51:21 -0700 Subject: [PATCH 14/40] Update routing context and MQ client version for compat. (#22) --- neon_iris/client.py | 3 ++- requirements/requirements.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/neon_iris/client.py b/neon_iris/client.py index 0ed3b3b..63eb2e5 100644 --- a/neon_iris/client.py +++ b/neon_iris/client.py @@ -254,7 +254,8 @@ def _build_message(self, msg_type: str, data: dict, "ident": ident or str(time()), "username": username, "user_profiles": user_profiles or list(), - "klat_data": {"routing_key": self.uid} + "mq": {"routing_key": self.uid, + "message_id": self.connection.create_unique_id()} }) def _send_utterance(self, utterance: str, lang: str, diff --git a/requirements/requirements.txt b/requirements/requirements.txt index d41d9c0..f8a89d2 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -2,5 +2,5 @@ click~=8.0 click-default-group~=1.2 neon-utils~=1.0 pyyaml>=5.4,<7.0.0 -neon-mq-connector~=0.7,>=0.7.1a3 +neon-mq-connector~=0.7,>=0.7.1a4 ovos-bus-client~=0.0.3 \ No newline at end of file From a83adaca5deb9bbddfcd629c254aec0c26d187f0 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Fri, 11 Aug 2023 20:51:38 +0000 Subject: [PATCH 15/40] Increment Version to 0.0.5a6 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index a9d6c0e..e124d6f 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a5" +__version__ = "0.0.5a6" From 326f5fe508ec7aa15c4339f24b2a9c4bf4d4c8a1 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Thu, 2 Nov 2023 18:45:38 -0700 Subject: [PATCH 16/40] Add NeonVoiceClient class for minimal remote audio client (#23) * Add NeonVoiceClient class for minimal remote audio client * Add CLI entrypoint for listener Add WW confirmation sound Update logging and bugfixes around MQ event handling --- neon_iris/cli.py | 13 ++- neon_iris/client.py | 6 +- neon_iris/res/start_listening.wav | Bin 0 -> 67090 bytes neon_iris/voice_client.py | 148 ++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 neon_iris/res/start_listening.wav create mode 100644 neon_iris/voice_client.py diff --git a/neon_iris/cli.py b/neon_iris/cli.py index f26f785..f0fa0a1 100644 --- a/neon_iris/cli.py +++ b/neon_iris/cli.py @@ -40,6 +40,7 @@ environ.setdefault("OVOS_CONFIG_BASE_FOLDER", "neon") environ.setdefault("OVOS_CONFIG_FILENAME", "diana.yaml") +# TODO: Define default config file from this package def _print_config(): @@ -80,7 +81,7 @@ def start_client(mq_config, user_config, lang, audio): if user_config: user_config = load_config_file(expanduser(user_config)) client = CLIClient(mq_config, user_config) - LOG.init({"level": logging.WARNING}) + LOG.init({"level": logging.WARNING}) # TODO: Debug flag? client.audio_enabled = audio click.echo("Enter '!{lang}' to change language\n" @@ -118,6 +119,16 @@ def start_client(mq_config, user_config, lang, audio): client.shutdown() +@neon_iris_cli.command(help="Create an MQ listener session") +def start_listener(): + from neon_iris.voice_client import NeonVoiceClient + from ovos_utils import wait_for_exit_signal + client = NeonVoiceClient() + _print_config() + wait_for_exit_signal() + client.shutdown() + + @neon_iris_cli.command(help="Transcribe an audio file") @click.option('--lang', '-l', default='en-us', help="language of input audio") diff --git a/neon_iris/client.py b/neon_iris/client.py index 63eb2e5..db99723 100644 --- a/neon_iris/client.py +++ b/neon_iris/client.py @@ -136,6 +136,8 @@ def handle_neon_response(self, channel, method, _, body): self._handle_profile_update(message) elif message.msg_type == "neon.clear_data": self._handle_clear_data(message) + elif message.msg_type == "klat.error": + self.handle_error_response(message) elif message.msg_type.endswith(".response"): self.handle_api_response(message) else: @@ -248,12 +250,14 @@ def _build_message(self, msg_type: str, data: dict, username: Optional[str] = None, user_profiles: Optional[list] = None, ident: str = None) -> Message: + user_profiles = user_profiles or [self.user_config] + username = username or user_profiles[0]['user']['username'] return Message(msg_type, data, {"client_name": self.client_name, "client": self._client, "ident": ident or str(time()), "username": username, - "user_profiles": user_profiles or list(), + "user_profiles": user_profiles, "mq": {"routing_key": self.uid, "message_id": self.connection.create_unique_id()} }) diff --git a/neon_iris/res/start_listening.wav b/neon_iris/res/start_listening.wav new file mode 100644 index 0000000000000000000000000000000000000000..c3202189162c402152ea2e3ddc6184e8f4c9c871 GIT binary patch literal 67090 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%0AZDfaoFE^R3&MWr4d?%qG7OaT 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 vYKK${sS^ Date: Fri, 3 Nov 2023 01:46:00 +0000 Subject: [PATCH 17/40] Increment Version to 0.0.5a7 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index e124d6f..7aa84c3 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a6" +__version__ = "0.0.5a7" From 65eaf62891fc42dbf3f5a0cd1d06cdabdef27375 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Tue, 7 Nov 2023 11:08:56 -0800 Subject: [PATCH 18/40] Add Gradio web UI with Docker Container (#24) * Initial Gradio web UI implementation with Dockerfile * Initial outline of settings with UI * Annotate web_client Move hard-coded params to configuration Update README to include configuration example Update Docker default configuration Add Docker automation * wip tts/stt and styling * add stt/tts, rearrange interface * Address feedback from https://github.com/NeonGeckoCom/neon-iris/pull/25 Update documentation Add missing system deps to Dockerfile * Patch audioread missing license (MIT) https://github.com/beetbox/audioread/blob/main/LICENSE * Refactor `docker` dependencies to `gradio` Cleanup logging Refactor to resolve warnings Resolve missing directory exception in audio input handling * Fix STT language handling --------- Co-authored-by: Daniel McKnight Co-authored-by: mikejgray --- .github/workflows/license_tests.yml | 2 + .github/workflows/publish_release.yml | 4 + .github/workflows/publish_test_build.yml | 6 +- Dockerfile | 21 ++ README.md | 42 ++-- docker_overlay/etc/neon/neon.yaml | 19 ++ neon_iris/cli.py | 7 + neon_iris/client.py | 11 +- neon_iris/web_client.py | 274 +++++++++++++++++++++++ requirements/gradio.txt | 3 + setup.py | 1 + 11 files changed, 372 insertions(+), 18 deletions(-) create mode 100644 Dockerfile create mode 100644 docker_overlay/etc/neon/neon.yaml create mode 100644 neon_iris/web_client.py create mode 100644 requirements/gradio.txt diff --git a/.github/workflows/license_tests.yml b/.github/workflows/license_tests.yml index a284db6..203ba25 100644 --- a/.github/workflows/license_tests.yml +++ b/.github/workflows/license_tests.yml @@ -6,3 +6,5 @@ on: jobs: license_tests: uses: neongeckocom/.github/.github/workflows/license_tests.yml@master + with: + packages-exclude: '^(precise-runner|fann2|tqdm|bs4|ovos-phal-plugin|ovos-skill|neon-core|nvidia|neon-phal-plugin|bitstruct|audioread).*' \ No newline at end of file diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index e4b3fa3..ff73f4a 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -10,3 +10,7 @@ jobs: build_and_publish_pypi_and_release: uses: neongeckocom/.github/.github/workflows/publish_stable_release.yml@master secrets: inherit + build_and_publish_docker: + needs: build_and_publish_pypi_and_release + uses: neongeckocom/.github/.github/workflows/publish_docker.yml@master + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/publish_test_build.yml b/.github/workflows/publish_test_build.yml index e1240fa..742ba53 100644 --- a/.github/workflows/publish_test_build.yml +++ b/.github/workflows/publish_test_build.yml @@ -9,9 +9,13 @@ on: - 'neon_iris/version.py' jobs: - build_and_publish_pypi: + publish_alpha_release: uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@master secrets: inherit with: version_file: "neon_iris/version.py" setup_py: "setup.py" + build_and_publish_docker: + needs: publish_alpha_release + uses: neongeckocom/.github/.github/workflows/publish_docker.yml@master + secrets: inherit \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..139a09a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.8-slim + +LABEL vendor=neon.ai \ + ai.neon.name="neon-iris" + +ENV OVOS_CONFIG_BASE_FOLDER neon +ENV OVOS_CONFIG_FILENAME neon.yaml +ENV XDG_CONFIG_HOME /config + +RUN apt update && \ + apt install -y ffmpeg + +ADD . /neon_iris +WORKDIR /neon_iris + +RUN pip install wheel && \ + pip install .[gradio] + +COPY docker_overlay/ / + +CMD ["iris", "start-gradio"] \ No newline at end of file diff --git a/README.md b/README.md index b2c7eb8..07cf069 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,34 @@ interacting with Neon systems remotely, via [MQ](https://github.com/NeonGeckoCom Install the Iris Python package with: `pip install neon-iris` The `iris` entrypoint is available to interact with a bus via CLI. Help is available via `iris --help`. +## Configuration +Configuration files can be specified via environment variables. By default, +`Iris` will read configuration from `~/.config/neon/diana.yaml` where +`XDG_CONFIG_HOME` is set to the default `~/.config`. +More information about configuration handling can be found +[in the docs](https://neongeckocom.github.io/neon-docs/quick_reference/configuration/). -## Debugging a Diana installation +A default configuration might look like: +```yaml +MQ: + server: neonaialpha.com + port: 25672 + users: + mq_handler: + user: neon_api_utils + password: Klatchat2021 +iris: + default_lang: en-us + languages: + - en-us + - uk-ua + webui_chatbot_label: "Neon AI" + webui_mic_label: "Speak with Neon" + webui_input_placeholder: "Chat with Neon" +``` + +## Interfacing with a Diana installation The `iris` CLI includes utilities for interacting with a `Diana` backend. -### Configuration -Configuration files can be specified via environment variables. By default, -`Iris` will set default values: -``` -OVOS_CONFIG_BASE_FOLDER=neon -OVOS_CONFIG_FILENAME=diana.yaml -``` -The example below would override defaults to read configuration from -`~/.config/mycroft/mycroft.conf`. -``` -export OVOS_CONFIG_BASE_FOLDER=mycroft -export OVOS_CONFIG_FILENAME=mycroft.conf -``` -More information about configuration handling can be found -[in the docs](https://neongeckocom.github.io/neon-docs/quick_reference/configuration/). \ No newline at end of file diff --git a/docker_overlay/etc/neon/neon.yaml b/docker_overlay/etc/neon/neon.yaml new file mode 100644 index 0000000..bf3a9b3 --- /dev/null +++ b/docker_overlay/etc/neon/neon.yaml @@ -0,0 +1,19 @@ +MQ: + server: neon-rabbitmq + port: 5672 + users: + mq_handler: + user: neon_api_utils + password: Klatchat2021 +iris: + webui_title: Neon AI + webui_description: Chat with Neon + webui_input_placeholder: Ask me something + server_address: "0.0.0.0" + server_port: 7860 + default_lang: en-us + languages: + - en-us + - fr-fr + - es-es + - uk-ua \ No newline at end of file diff --git a/neon_iris/cli.py b/neon_iris/cli.py index f0fa0a1..e7e6cef 100644 --- a/neon_iris/cli.py +++ b/neon_iris/cli.py @@ -129,6 +129,13 @@ def start_listener(): client.shutdown() +@neon_iris_cli.command(help="Create a GradIO Client session") +def start_gradio(): + from neon_iris.web_client import GradIOClient + chat = GradIOClient() + chat.run() + + @neon_iris_cli.command(help="Transcribe an audio file") @click.option('--lang', '-l', default='en-us', help="language of input audio") diff --git a/neon_iris/client.py b/neon_iris/client.py index db99723..3c9bdc5 100644 --- a/neon_iris/client.py +++ b/neon_iris/client.py @@ -74,6 +74,10 @@ def uid(self) -> str: """ return self._uid + @property + def default_username(self) -> str: + return self._user_config["user"]["username"] + @property def user_config(self) -> dict: """ @@ -264,6 +268,8 @@ def _build_message(self, msg_type: str, data: dict, def _send_utterance(self, utterance: str, lang: str, username: str, user_profiles: list): + username = username or self.default_username + user_profiles = user_profiles or [self.user_config] message = self._build_message("recognizer_loop:utterance", {"utterances": [utterance], "lang": lang}, username, user_profiles) @@ -277,7 +283,9 @@ def _send_audio(self, audio_file: str, lang: str, audio_data = encode_file_to_base64_string(audio_file) message = self._build_message("neon.audio_input", {"lang": lang, - "audio_data": audio_data}, + "audio_data": audio_data, + "utterances": []}, + # TODO: `utterances` patching mq connector username, user_profiles) serialized = {"msg_type": message.msg_type, "data": message.data, @@ -290,6 +298,7 @@ def _send_serialized_message(self, serialized: dict): self._connection.connection, queue="neon_chat_api_request", request_data=serialized) + LOG.debug(f"emitted {serialized.get('msg_type')}") except Exception as e: LOG.exception(e) self.shutdown() diff --git a/neon_iris/web_client.py b/neon_iris/web_client.py new file mode 100644 index 0000000..7e6f92c --- /dev/null +++ b/neon_iris/web_client.py @@ -0,0 +1,274 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2021 Neongecko.com Inc. +# BSD-3 +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from os import makedirs +from os.path import isfile, join, isdir +from time import time +from typing import List, Optional + +import gradio + +from threading import Event +from ovos_bus_client import Message +from ovos_config import Configuration +from ovos_utils import LOG +from neon_utils.file_utils import decode_base64_string_to_file +from ovos_utils.xdg_utils import xdg_data_home + +from neon_iris.client import NeonAIClient +import librosa +import soundfile as sf + + +class GradIOClient(NeonAIClient): + def __init__(self, lang: str = None): + config = Configuration() + self.config = config.get('iris') or dict() + NeonAIClient.__init__(self, config.get("MQ")) + self._await_response = Event() + self._response = None + self._current_tts = None + self._audio_path = join(xdg_data_home(), "iris", "stt") + if not isdir(self._audio_path): + makedirs(self._audio_path) + self.default_lang = lang or self.config.get('default_lang') + self.chat_ui = gradio.Blocks() + + @property + def lang(self): + return self.user_config['speech']['stt_language'] or self.default_lang + + @property + def supported_languages(self) -> List[str]: + """ + Get a list of supported languages from configuration + @returns: list of BCP-47 language codes + """ + return self.config.get('languages') or [self.default_lang] + + def update_profile(self, stt_lang: str, tts_lang: str, tts_lang_2: str): + """ + Callback to handle user settings changes from the web UI + """ + # TODO: Per-client config. The current method of referencing + # `self._user_config` means every user shares one configuration which + # does not scale. This client should probably override the + # `self.user_config` property and implement a method for storing user + # configuration in cookies or similar. + profile_update = {"speech": {"stt_language": stt_lang, + "tts_language": tts_lang, + "secondary_tts_language": tts_lang_2}} + from neon_utils.user_utils import apply_local_user_profile_updates + apply_local_user_profile_updates(profile_update, self._user_config) + + def send_audio(self, audio_file: str, lang: str = "en-us", + username: Optional[str] = None, + user_profiles: Optional[list] = None): + """ + @param audio_file: path to wav audio file to send to speech module + @param lang: language code associated with request + @param username: username associated with request + @param user_profiles: user profiles expecting a response + """ + # TODO: Audio conversion is really slow here. check ovos-stt-http-server + audio_file = self.convert_audio(audio_file) + self._send_audio(audio_file, lang, username, user_profiles) + + def convert_audio(self, audio_file: str, target_sr=16000, target_channels=1, + dtype='int16') -> str: + """ + @param audio_file: path to audio file to convert for speech model + @returns: path to converted audio file + """ + # Load the audio file + y, sr = librosa.load(audio_file, sr=None, mono=False) # Load without changing sample rate or channels + + # If the file has more than one channel, mix it down to one channel + if y.ndim > 1 and target_channels == 1: + y = librosa.to_mono(y) + + # Resample the audio to the target sample rate + y_resampled = librosa.resample(y, orig_sr=sr, target_sr=target_sr) + + # Ensure the audio array is in the correct format (int16 for 2-byte samples) + y_resampled = (y_resampled * (2 ** (8 * 2 - 1))).astype(dtype) + + output_path = join(self._audio_path, f"{time()}.wav") + # Save the audio file with the new sample rate and sample width + sf.write(output_path, y_resampled, target_sr, format='WAV', subtype='PCM_16') + LOG.info(f"Converted audio file to {output_path}") + return output_path + + def on_user_input(self, utterance: str, *args, **kwargs) -> str: + """ + Callback to handle textual user input + @param utterance: String utterance submitted by the user + @returns: String response from Neon (or "ERROR") + """ + # TODO: This should probably queue with a separate iterator thread + LOG.debug(f"args={args}|kwargs={kwargs}") + self._await_response.clear() + self._response = None + if utterance: + LOG.info(f"Sending utterance: {utterance} with lang: {self.lang}") + self.send_utterance(utterance, self.lang) + else: + LOG.info(f"Sending audio: {args[1]} with lang: {self.lang}") + self.send_audio(args[1], self.lang) + self._await_response.wait(30) + self._response = self._response or "ERROR" + LOG.info(f"Got response={self._response}") + return self._response + + def play_tts(self): + LOG.info(f"Playing most recent TTS file {self._current_tts}") + return self._current_tts + + def run(self): + """ + Blocking method to start the web server + """ + title = self.config.get("webui_title", "Neon AI") + description = self.config.get("webui_description", "Chat With Neon") + chatbot = self.config.get("webui_chatbot_label") or description + speech = self.config.get("webui_mic_label") or description + placeholder = self.config.get("webui_input_placeholder", + "Ask me something") + address = self.config.get("server_address") or "0.0.0.0" + port = self.config.get("server_port") or 7860 + + chatbot = gradio.Chatbot(label=chatbot) + textbox = gradio.Textbox(placeholder=placeholder) + + with self.chat_ui as blocks: + # Define primary UI + audio_input = gradio.Audio(source="microphone", + type="filepath", + label=speech) + gradio.ChatInterface(self.on_user_input, + chatbot=chatbot, + textbox=textbox, + additional_inputs=[audio_input], + title=title, + retry_btn=None, + undo_btn=None, ) + tts_audio = gradio.Audio(autoplay=True, visible=True, + label="Neon's Response") + tts_button = gradio.Button("Play TTS") + tts_button.click(self.play_tts, + outputs=[tts_audio]) + # Define settings UI + with gradio.Row(): + with gradio.Column(): + stt_lang = gradio.Radio(label="Input Language", + choices=self.supported_languages, + value=self.lang) + tts_lang = gradio.Radio(label="Response Language", + choices=self.supported_languages, + value=self.lang) + tts_lang_2 = gradio.Radio(label="Second Response Language", + choices=[None] + + self.supported_languages, + value=None) + submit = gradio.Button("Update User Settings") + with gradio.Column(): + # TODO: Unit settings + pass + with gradio.Column(): + # TODO: Location settings + pass + with gradio.Column(): + # TODO Name settings + pass + submit.click(self.update_profile, + inputs=[stt_lang, tts_lang, tts_lang_2]) + blocks.launch(server_name=address, server_port=port) + + def handle_klat_response(self, message: Message): + """ + Handle a valid response from Neon. This includes text and base64-encoded + audio in all requested languages. + @param message: Neon response message + """ + LOG.debug(f"Response_data={message.data}") + resp_data = message.data["responses"] + files = [] + sentences = [] + for lang, response in resp_data.items(): + sentences.append(response.get("sentence")) + if response.get("audio"): + for gender, data in response["audio"].items(): + filepath = "/".join([self.audio_cache_dir] + + response[gender].split('/')[-4:]) + # TODO: This only plays the most recent, so it doesn't support + # multiple languages + self._current_tts = filepath + files.append(filepath) + if not isfile(filepath): + decode_base64_string_to_file(data, filepath) + self._response = "\n".join(sentences) + self._await_response.set() + + def handle_complete_intent_failure(self, message: Message): + """ + Handle an intent failure response from Neon. This should not happen and + indicates the Neon service is probably not yet ready. + @param message: Neon intent failure response message + """ + self._response = "ERROR" + self._await_response.set() + + def handle_api_response(self, message: Message): + """ + Catch-all handler for `.response` messages routed to this client that + are not explicitly handled (i.e. get_stt, get_tts) + @param message: Response message to something emitted by this client + """ + LOG.debug(f"Got {message.msg_type}: {message.data}") + + def handle_error_response(self, message: Message): + """ + Handle an error response from the MQ service attached to Neon. This + usually indicates a malformed input. + @param message: Response message indicating reason for failure + """ + LOG.error(f"Error response: {message.data}") + + def clear_caches(self, message: Message): + """ + Handle a request from Neon to clear cached data. + @param message: Message requesting cache deletion. The context of this + message will include the requesting user for user-specific caches + """ + # TODO: remove cached TTS audio responses + + def clear_media(self, message: Message): + """ + Handle a request from Neon to clear local multimedia. This method does + not apply to this client as there is no user-generated media to clear. + @param message: Message requesting media deletion + """ + pass diff --git a/requirements/gradio.txt b/requirements/gradio.txt new file mode 100644 index 0000000..fb2c552 --- /dev/null +++ b/requirements/gradio.txt @@ -0,0 +1,3 @@ +gradio~=3.28 +librosa~=0.9 +soundfile~=0.12 \ No newline at end of file diff --git a/setup.py b/setup.py index 1a20b6a..99fc4f4 100644 --- a/setup.py +++ b/setup.py @@ -71,6 +71,7 @@ def get_requirements(requirements_filename: str): ], python_requires='>=3.6', install_requires=get_requirements("requirements.txt"), + extras_require={"gradio": get_requirements("gradio.txt")}, entry_points={ 'console_scripts': ['iris=neon_iris.cli:neon_iris_cli'] } From 1f13ed1e2a048598d226ca95b05c5ca2efba2b39 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Tue, 7 Nov 2023 19:09:18 +0000 Subject: [PATCH 19/40] Increment Version to 0.0.5a8 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 7aa84c3..3c733a3 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a7" +__version__ = "0.0.5a8" From 37bf01f90f39bb33026629c1c708d00e892d662e Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Wed, 8 Nov 2023 09:11:20 -0800 Subject: [PATCH 20/40] Threaded input handling and multi-session support (#31) * Prevent sending an input until the previous response has been handled This would ideally use a queue but that will require using a different UI since the gradio ChatBot expects each input to return a value synchronously Relates to #26 * Implement gradio State to track a session ID Update handling so TTS responses are attached to a specific browser session * Implement session-specific profile settings * Add remaining user profile params to UI --------- Co-authored-by: Daniel McKnight --- neon_iris/client.py | 27 ++++++--- neon_iris/web_client.py | 128 ++++++++++++++++++++++++++++++---------- 2 files changed, 115 insertions(+), 40 deletions(-) diff --git a/neon_iris/client.py b/neon_iris/client.py index 3c9bdc5..cb637f7 100644 --- a/neon_iris/client.py +++ b/neon_iris/client.py @@ -38,6 +38,7 @@ from typing import Optional from uuid import uuid4 from ovos_bus_client.message import Message +from ovos_utils.json_helper import merge_dict from pika.exceptions import StreamLostError from neon_utils.configuration_utils import get_neon_user_config from neon_utils.mq_utils import NeonMQHandler @@ -228,27 +229,31 @@ def _clear_audio_cache(): def send_utterance(self, utterance: str, lang: str = "en-us", username: Optional[str] = None, - user_profiles: Optional[list] = None): + user_profiles: Optional[list] = None, + context: Optional[dict] = None): """ Optionally override this to queue text inputs or do any pre-parsing :param utterance: utterance to submit to skills module :param lang: language code associated with request :param username: username associated with request :param user_profiles: user profiles expecting a response + :param context: Optional dict context to add to emitted message """ - self._send_utterance(utterance, lang, username, user_profiles) + self._send_utterance(utterance, lang, username, user_profiles, context) def send_audio(self, audio_file: str, lang: str = "en-us", username: Optional[str] = None, - user_profiles: Optional[list] = None): + user_profiles: Optional[list] = None, + context: Optional[dict] = None): """ Optionally override this to queue audio inputs or do any pre-parsing :param audio_file: path to audio file to send to speech module :param lang: language code associated with request :param username: username associated with request :param user_profiles: user profiles expecting a response + :param context: Optional dict context to add to emitted message """ - self._send_audio(audio_file, lang, username, user_profiles) + self._send_audio(audio_file, lang, username, user_profiles, context) def _build_message(self, msg_type: str, data: dict, username: Optional[str] = None, @@ -267,7 +272,9 @@ def _build_message(self, msg_type: str, data: dict, }) def _send_utterance(self, utterance: str, lang: str, - username: str, user_profiles: list): + username: str, user_profiles: list, + context: Optional[dict] = None): + context = context or dict() username = username or self.default_username user_profiles = user_profiles or [self.user_config] message = self._build_message("recognizer_loop:utterance", @@ -275,11 +282,14 @@ def _send_utterance(self, utterance: str, lang: str, "lang": lang}, username, user_profiles) serialized = {"msg_type": message.msg_type, "data": message.data, - "context": message.context} + "context": merge_dict(message.context, context, + new_only=True)} self._send_serialized_message(serialized) def _send_audio(self, audio_file: str, lang: str, - username: str, user_profiles: list): + username: str, user_profiles: list, + context: Optional[dict] = None): + context = context or dict() audio_data = encode_file_to_base64_string(audio_file) message = self._build_message("neon.audio_input", {"lang": lang, @@ -289,7 +299,8 @@ def _send_audio(self, audio_file: str, lang: str, username, user_profiles) serialized = {"msg_type": message.msg_type, "data": message.data, - "context": message.context} + "context": merge_dict(message.context, context, + new_only=True)} self._send_serialized_message(serialized) def _send_serialized_message(self, serialized: dict): diff --git a/neon_iris/web_client.py b/neon_iris/web_client.py index 7e6f92c..c8ae560 100644 --- a/neon_iris/web_client.py +++ b/neon_iris/web_client.py @@ -27,7 +27,8 @@ from os import makedirs from os.path import isfile, join, isdir from time import time -from typing import List, Optional +from typing import List, Optional, Dict +from uuid import uuid4 import gradio @@ -35,6 +36,8 @@ from ovos_bus_client import Message from ovos_config import Configuration from ovos_utils import LOG +from ovos_utils.json_helper import merge_dict + from neon_utils.file_utils import decode_base64_string_to_file from ovos_utils.xdg_utils import xdg_data_home @@ -50,12 +53,15 @@ def __init__(self, lang: str = None): NeonAIClient.__init__(self, config.get("MQ")) self._await_response = Event() self._response = None - self._current_tts = None + self._current_tts = dict() + self._profiles: Dict[str, dict] = dict() self._audio_path = join(xdg_data_home(), "iris", "stt") if not isdir(self._audio_path): makedirs(self._audio_path) self.default_lang = lang or self.config.get('default_lang') self.chat_ui = gradio.Blocks() + LOG.name = "iris" + LOG.init(self.config.get("logs")) @property def lang(self): @@ -69,24 +75,52 @@ def supported_languages(self) -> List[str]: """ return self.config.get('languages') or [self.default_lang] - def update_profile(self, stt_lang: str, tts_lang: str, tts_lang_2: str): + def _start_session(self): + sid = uuid4().hex + self._current_tts[sid] = None + self._profiles[sid] = self.user_config + self._profiles[sid]['user']['username'] = sid + return sid + + def update_profile(self, stt_lang: str, tts_lang: str, tts_lang_2: str, + time: int, date: str, uom: str, city: str, state: str, + country: str, first: str, middle: str, last: str, + pref_name: str, email: str, session_id: str): """ Callback to handle user settings changes from the web UI """ - # TODO: Per-client config. The current method of referencing - # `self._user_config` means every user shares one configuration which - # does not scale. This client should probably override the - # `self.user_config` property and implement a method for storing user - # configuration in cookies or similar. + location_dict = dict() + if any((city, state, country)): + from neon_utils.location_utils import get_coordinates, get_timezone + try: + location_dict = {"city": city, "state": state, + "country": country} + lat, lon = get_coordinates(location_dict) + location_dict["lat"] = lat + location_dict["lng"] = lon + location_dict["tz"], location_dict["utc"] = get_timezone(lat, + lon) + LOG.debug(f"Got location update: {location_dict}") + except Exception as e: + LOG.exception(e) + profile_update = {"speech": {"stt_language": stt_lang, "tts_language": tts_lang, - "secondary_tts_language": tts_lang_2}} - from neon_utils.user_utils import apply_local_user_profile_updates - apply_local_user_profile_updates(profile_update, self._user_config) + "secondary_tts_language": tts_lang_2}, + "units": {"time": time, "date": date, "measure": uom}, + "location": location_dict, + "user": {"first_name": first, "middle_name": middle, + "last_name": last, + "preferred_name": pref_name, "email": email}} + old_profile = self._profiles.get(session_id) or self.user_config + self._profiles[session_id] = merge_dict(old_profile, profile_update) + LOG.info(f"Updated profile for: {session_id}") + return session_id def send_audio(self, audio_file: str, lang: str = "en-us", username: Optional[str] = None, - user_profiles: Optional[list] = None): + user_profiles: Optional[list] = None, + context: Optional[dict] = None): """ @param audio_file: path to wav audio file to send to speech module @param lang: language code associated with request @@ -95,7 +129,7 @@ def send_audio(self, audio_file: str, lang: str = "en-us", """ # TODO: Audio conversion is really slow here. check ovos-stt-http-server audio_file = self.convert_audio(audio_file) - self._send_audio(audio_file, lang, username, user_profiles) + self._send_audio(audio_file, lang, username, user_profiles, context) def convert_audio(self, audio_file: str, target_sr=16000, target_channels=1, dtype='int16') -> str: @@ -128,29 +162,37 @@ def on_user_input(self, utterance: str, *args, **kwargs) -> str: @param utterance: String utterance submitted by the user @returns: String response from Neon (or "ERROR") """ - # TODO: This should probably queue with a separate iterator thread + LOG.debug(f"Input received") + if not self._await_response.wait(30): + LOG.error("Previous response not completed after 30 seconds") LOG.debug(f"args={args}|kwargs={kwargs}") self._await_response.clear() self._response = None + gradio_id = args[2] if utterance: LOG.info(f"Sending utterance: {utterance} with lang: {self.lang}") - self.send_utterance(utterance, self.lang) + self.send_utterance(utterance, self.lang, username=gradio_id, + user_profiles=[self._profiles[gradio_id]], + context={"gradio": {"session": gradio_id}}) else: LOG.info(f"Sending audio: {args[1]} with lang: {self.lang}") - self.send_audio(args[1], self.lang) + self.send_audio(args[1], self.lang, username=gradio_id, + user_profiles=[self._profiles[gradio_id]], + context={"gradio": {"session": gradio_id}}) self._await_response.wait(30) self._response = self._response or "ERROR" LOG.info(f"Got response={self._response}") return self._response - def play_tts(self): + def play_tts(self, session_id: str): LOG.info(f"Playing most recent TTS file {self._current_tts}") - return self._current_tts + return self._current_tts.get(session_id), session_id def run(self): """ Blocking method to start the web server """ + self._await_response.set() title = self.config.get("webui_title", "Neon AI") description = self.config.get("webui_description", "Chat With Neon") chatbot = self.config.get("webui_chatbot_label") or description @@ -164,6 +206,8 @@ def run(self): textbox = gradio.Textbox(placeholder=placeholder) with self.chat_ui as blocks: + client_session = gradio.State(self._start_session()) + client_session.attach_load_event(self._start_session, None) # Define primary UI audio_input = gradio.Audio(source="microphone", type="filepath", @@ -171,7 +215,7 @@ def run(self): gradio.ChatInterface(self.on_user_input, chatbot=chatbot, textbox=textbox, - additional_inputs=[audio_input], + additional_inputs=[audio_input, client_session], title=title, retry_btn=None, undo_btn=None, ) @@ -179,7 +223,8 @@ def run(self): label="Neon's Response") tts_button = gradio.Button("Play TTS") tts_button.click(self.play_tts, - outputs=[tts_audio]) + inputs=[client_session], + outputs=[tts_audio, client_session]) # Define settings UI with gradio.Row(): with gradio.Column(): @@ -193,18 +238,36 @@ def run(self): choices=[None] + self.supported_languages, value=None) - submit = gradio.Button("Update User Settings") with gradio.Column(): - # TODO: Unit settings - pass + time_format = gradio.Radio(label="Time Format", + choices=[12, 24], + value=12) + date_format = gradio.Radio(label="Date Format", + choices=["MDY", "YMD", "DMY", + "YDM"], + value="MDY") + unit_of_measure = gradio.Radio(label="Units of Measure", + choices=["imperial", + "metric"], + value="imperial") with gradio.Column(): - # TODO: Location settings - pass + city = gradio.Textbox(label="City") + state = gradio.Textbox(label="State") + country = gradio.Textbox(label="Country") with gradio.Column(): - # TODO Name settings - pass + first_name = gradio.Textbox(label="First Name") + middle_name = gradio.Textbox(label="Middle Name") + last_name = gradio.Textbox(label="Last Name") + pref_name = gradio.Textbox(label="Preferred Name") + email_addr = gradio.Textbox(label="Email Address") + # TODO: DoB, pic, about, phone? + submit = gradio.Button("Update User Settings") submit.click(self.update_profile, - inputs=[stt_lang, tts_lang, tts_lang_2]) + inputs=[stt_lang, tts_lang, tts_lang_2, time_format, + date_format, unit_of_measure, city, state, + country, first_name, middle_name, last_name, + pref_name, email_addr, client_session], + outputs=[client_session]) blocks.launch(server_name=address, server_port=port) def handle_klat_response(self, message: Message): @@ -213,19 +276,20 @@ def handle_klat_response(self, message: Message): audio in all requested languages. @param message: Neon response message """ - LOG.debug(f"Response_data={message.data}") + LOG.debug(f"gradio context={message.context['gradio']}") resp_data = message.data["responses"] files = [] sentences = [] + session = message.context['gradio']['session'] for lang, response in resp_data.items(): sentences.append(response.get("sentence")) if response.get("audio"): for gender, data in response["audio"].items(): filepath = "/".join([self.audio_cache_dir] + response[gender].split('/')[-4:]) - # TODO: This only plays the most recent, so it doesn't support - # multiple languages - self._current_tts = filepath + # TODO: This only plays the most recent, so it doesn't + # support multiple languages or multi-utterance responses + self._current_tts[session] = filepath files.append(filepath) if not isfile(filepath): decode_base64_string_to_file(data, filepath) From ea9ce3e40ebe8b92b09c07030ccd3461d01082bf Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Wed, 8 Nov 2023 17:11:42 +0000 Subject: [PATCH 21/40] Increment Version to 0.0.5a9 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 3c733a3..4261e1c 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a8" +__version__ = "0.0.5a9" From 49e5012c74c23b6a85397c922c2aedada578e5c3 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Wed, 8 Nov 2023 16:27:38 -0800 Subject: [PATCH 22/40] Fix web_client language handling to respect configured input language (#32) Co-authored-by: Daniel McKnight --- neon_iris/web_client.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/neon_iris/web_client.py b/neon_iris/web_client.py index c8ae560..b9a0db2 100644 --- a/neon_iris/web_client.py +++ b/neon_iris/web_client.py @@ -63,8 +63,9 @@ def __init__(self, lang: str = None): LOG.name = "iris" LOG.init(self.config.get("logs")) - @property - def lang(self): + def get_lang(self, session_id: str): + if session_id and session_id in self._profiles: + return self._profiles[session_id]['speech']['stt_language'] return self.user_config['speech']['stt_language'] or self.default_lang @property @@ -169,14 +170,15 @@ def on_user_input(self, utterance: str, *args, **kwargs) -> str: self._await_response.clear() self._response = None gradio_id = args[2] + lang = self.get_lang(gradio_id) if utterance: - LOG.info(f"Sending utterance: {utterance} with lang: {self.lang}") - self.send_utterance(utterance, self.lang, username=gradio_id, + LOG.info(f"Sending utterance: {utterance} with lang: {lang}") + self.send_utterance(utterance, lang, username=gradio_id, user_profiles=[self._profiles[gradio_id]], context={"gradio": {"session": gradio_id}}) else: - LOG.info(f"Sending audio: {args[1]} with lang: {self.lang}") - self.send_audio(args[1], self.lang, username=gradio_id, + LOG.info(f"Sending audio: {args[1]} with lang: {lang}") + self.send_audio(args[1], lang, username=gradio_id, user_profiles=[self._profiles[gradio_id]], context={"gradio": {"session": gradio_id}}) self._await_response.wait(30) @@ -228,12 +230,13 @@ def run(self): # Define settings UI with gradio.Row(): with gradio.Column(): + lang = self.get_lang(client_session.value) stt_lang = gradio.Radio(label="Input Language", choices=self.supported_languages, - value=self.lang) + value=lang) tts_lang = gradio.Radio(label="Response Language", choices=self.supported_languages, - value=self.lang) + value=lang) tts_lang_2 = gradio.Radio(label="Second Response Language", choices=[None] + self.supported_languages, From 68378bd3d9d46422c7afca63f9166e28603a1e72 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Thu, 9 Nov 2023 00:27:51 +0000 Subject: [PATCH 23/40] Increment Version to 0.0.5a10 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 4261e1c..0d68822 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a9" +__version__ = "0.0.5a10" From 0e93afe1d55e5f51895ad5f66eb7b6ab7b396109 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:27:56 -0800 Subject: [PATCH 24/40] Handle user profile updates from Neon (#36) Handle MQ connection errors with useful log in CLI Update default config for Docker Co-authored-by: Daniel McKnight --- docker_overlay/etc/neon/neon.yaml | 26 +++++++++++++++++++++++++- neon_iris/cli.py | 8 ++++++-- neon_iris/web_client.py | 9 +++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docker_overlay/etc/neon/neon.yaml b/docker_overlay/etc/neon/neon.yaml index bf3a9b3..dea30dc 100644 --- a/docker_overlay/etc/neon/neon.yaml +++ b/docker_overlay/etc/neon/neon.yaml @@ -16,4 +16,28 @@ iris: - en-us - fr-fr - es-es - - uk-ua \ No newline at end of file + - de-de + - it-it + - uk-ua + - nl-nl + - pt-pt + - ca-es + +location: + city: + code: Renton + name: Renton + state: + code: WA + name: Washington + country: + code: US + name: United States + coordinate: + latitude: 47.482880 + longitude: -122.217064 + timezone: + code: America/Los_Angeles + name: Pacific Standard Time + dstOffset: 3600000 + offset: -28800000 diff --git a/neon_iris/cli.py b/neon_iris/cli.py index e7e6cef..90be32f 100644 --- a/neon_iris/cli.py +++ b/neon_iris/cli.py @@ -132,8 +132,12 @@ def start_listener(): @neon_iris_cli.command(help="Create a GradIO Client session") def start_gradio(): from neon_iris.web_client import GradIOClient - chat = GradIOClient() - chat.run() + _print_config() + try: + chat = GradIOClient() + chat.run() + except OSError: + click.echo("Unable to connect to MQ server") @neon_iris_cli.command(help="Transcribe an audio file") diff --git a/neon_iris/web_client.py b/neon_iris/web_client.py index b9a0db2..011d21e 100644 --- a/neon_iris/web_client.py +++ b/neon_iris/web_client.py @@ -316,6 +316,15 @@ def handle_api_response(self, message: Message): """ LOG.debug(f"Got {message.msg_type}: {message.data}") + def _handle_profile_update(self, message: Message): + updated_profile = message.data["profile"] + session_id = updated_profile['user']['username'] + if session_id in self._profiles: + LOG.info(f"Got profile update for {session_id}") + self._profiles[session_id] = updated_profile + else: + LOG.warning(f"Ignoring profile update for {session_id}") + def handle_error_response(self, message: Message): """ Handle an error response from the MQ service attached to Neon. This From de0f6062ac65264d200d270569c08685b1a86817 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Tue, 21 Nov 2023 01:28:16 +0000 Subject: [PATCH 25/40] Increment Version to 0.0.5a11 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 0d68822..d8dc31d 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a10" +__version__ = "0.0.5a11" From f14f71c42b8d5a5f31c3b188913ac7d362c4698c Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Mon, 20 Nov 2023 19:14:46 -0800 Subject: [PATCH 26/40] Remove audio resampling and add timing context support (#33) * Remove audio resampling moved to neon-speech https://github.com/NeonGeckoCom/neon_speech/pull/180 Closes #28 * Add timing context and logging to go with: https://github.com/NeonGeckoCom/neon_speech/pull/181 https://github.com/NeonGeckoCom/neon_audio/pull/154 * More timing metrics and logging * Refactor timing and add debug log Prevent response error from affecting next input --------- Co-authored-by: Daniel McKnight --- neon_iris/client.py | 27 ++++++++++++++++++-- neon_iris/web_client.py | 55 +++++++++-------------------------------- 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/neon_iris/client.py b/neon_iris/client.py index cb637f7..9ce374b 100644 --- a/neon_iris/client.py +++ b/neon_iris/client.py @@ -41,6 +41,7 @@ from ovos_utils.json_helper import merge_dict from pika.exceptions import StreamLostError from neon_utils.configuration_utils import get_neon_user_config +from neon_utils.metrics_utils import Stopwatch from neon_utils.mq_utils import NeonMQHandler from neon_utils.socket_utils import b64_to_dict from neon_utils.file_utils import decode_base64_string_to_file, \ @@ -49,6 +50,8 @@ from ovos_utils.xdg_utils import xdg_config_home, xdg_cache_home from ovos_config.config import Configuration +_stopwatch = Stopwatch() + class NeonAIClient: def __init__(self, mq_config: dict = None, config_dir: str = None): @@ -128,10 +131,24 @@ def handle_neon_response(self, channel, method, _, body): Override this method to handle Neon Responses """ channel.basic_ack(delivery_tag=method.delivery_tag) - response = b64_to_dict(body) + recv_time = time() + with _stopwatch: + response = b64_to_dict(body) + LOG.debug(f"Message deserialized in {_stopwatch.time}s") message = Message(response.get('msg_type'), response.get('data'), response.get('context')) - LOG.info(message.msg_type) + + # Get timing data and log + message.context.setdefault("timing", {}) + resp_time = message.context['timing'].get('response_sent', recv_time) + if recv_time != resp_time: + transit_time = recv_time - resp_time + message.context['timing']['client_from_core'] = transit_time + LOG.debug(f"Response MQ transit time={transit_time}") + handling_time = recv_time - message.context['timing'].get('client_sent', + recv_time) + LOG.info(f"{message.msg_type} handled in {handling_time}") + LOG.debug(f"{pformat(message.context['timing'])}") if message.msg_type == "klat.response": LOG.info("Handling klat response event") self.handle_klat_response(message) @@ -267,6 +284,7 @@ def _build_message(self, msg_type: str, data: dict, "ident": ident or str(time()), "username": username, "user_profiles": user_profiles, + "timing": {}, "mq": {"routing_key": self.uid, "message_id": self.connection.create_unique_id()} }) @@ -305,6 +323,11 @@ def _send_audio(self, audio_file: str, lang: str, def _send_serialized_message(self, serialized: dict): try: + serialized['context']['timing']['client_sent'] = time() + if serialized['context']['timing'].get('gradio_sent'): + serialized['context']['timing']['iris_input_handling'] = \ + serialized['context']['timing']['client_sent'] - \ + serialized['context']['timing']['gradio_sent'] self.connection.emit_mq_message( self._connection.connection, queue="neon_chat_api_request", diff --git a/neon_iris/web_client.py b/neon_iris/web_client.py index 011d21e..480a832 100644 --- a/neon_iris/web_client.py +++ b/neon_iris/web_client.py @@ -27,7 +27,7 @@ from os import makedirs from os.path import isfile, join, isdir from time import time -from typing import List, Optional, Dict +from typing import List, Dict from uuid import uuid4 import gradio @@ -118,55 +118,18 @@ def update_profile(self, stt_lang: str, tts_lang: str, tts_lang_2: str, LOG.info(f"Updated profile for: {session_id}") return session_id - def send_audio(self, audio_file: str, lang: str = "en-us", - username: Optional[str] = None, - user_profiles: Optional[list] = None, - context: Optional[dict] = None): - """ - @param audio_file: path to wav audio file to send to speech module - @param lang: language code associated with request - @param username: username associated with request - @param user_profiles: user profiles expecting a response - """ - # TODO: Audio conversion is really slow here. check ovos-stt-http-server - audio_file = self.convert_audio(audio_file) - self._send_audio(audio_file, lang, username, user_profiles, context) - - def convert_audio(self, audio_file: str, target_sr=16000, target_channels=1, - dtype='int16') -> str: - """ - @param audio_file: path to audio file to convert for speech model - @returns: path to converted audio file - """ - # Load the audio file - y, sr = librosa.load(audio_file, sr=None, mono=False) # Load without changing sample rate or channels - - # If the file has more than one channel, mix it down to one channel - if y.ndim > 1 and target_channels == 1: - y = librosa.to_mono(y) - - # Resample the audio to the target sample rate - y_resampled = librosa.resample(y, orig_sr=sr, target_sr=target_sr) - - # Ensure the audio array is in the correct format (int16 for 2-byte samples) - y_resampled = (y_resampled * (2 ** (8 * 2 - 1))).astype(dtype) - - output_path = join(self._audio_path, f"{time()}.wav") - # Save the audio file with the new sample rate and sample width - sf.write(output_path, y_resampled, target_sr, format='WAV', subtype='PCM_16') - LOG.info(f"Converted audio file to {output_path}") - return output_path - def on_user_input(self, utterance: str, *args, **kwargs) -> str: """ Callback to handle textual user input @param utterance: String utterance submitted by the user @returns: String response from Neon (or "ERROR") """ + input_time = time() LOG.debug(f"Input received") if not self._await_response.wait(30): LOG.error("Previous response not completed after 30 seconds") LOG.debug(f"args={args}|kwargs={kwargs}") + in_queue = time() - input_time self._await_response.clear() self._response = None gradio_id = args[2] @@ -175,13 +138,19 @@ def on_user_input(self, utterance: str, *args, **kwargs) -> str: LOG.info(f"Sending utterance: {utterance} with lang: {lang}") self.send_utterance(utterance, lang, username=gradio_id, user_profiles=[self._profiles[gradio_id]], - context={"gradio": {"session": gradio_id}}) + context={"gradio": {"session": gradio_id}, + "timing": {"wait_in_queue": in_queue, + "gradio_sent": time()}}) else: LOG.info(f"Sending audio: {args[1]} with lang: {lang}") self.send_audio(args[1], lang, username=gradio_id, user_profiles=[self._profiles[gradio_id]], - context={"gradio": {"session": gradio_id}}) - self._await_response.wait(30) + context={"gradio": {"session": gradio_id}, + "timing": {"wait_in_queue": in_queue, + "gradio_sent": time()}}) + if not self._await_response.wait(30): + LOG.error("No response received after 30s") + self._await_response.set() self._response = self._response or "ERROR" LOG.info(f"Got response={self._response}") return self._response From 9d62d034c1107e7dc60e2342d8c25b10f86aaab5 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Tue, 21 Nov 2023 03:15:04 +0000 Subject: [PATCH 27/40] Increment Version to 0.0.5a12 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index d8dc31d..b47f401 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a11" +__version__ = "0.0.5a12" From f3ece7b2315f34a1b9f6cbd07acfcc66aa1c5f45 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Tue, 21 Nov 2023 09:02:41 -0800 Subject: [PATCH 28/40] Refactor GradIO UI to use Chatbot class directly (#34) Relocate audio input box next to `Submit` button Clear audio input upon response to input Move `Play TTS` button to visually match line above Updates default web UI labels in Docker config Closes #30 Closes #29 Co-authored-by: Daniel McKnight --- docker_overlay/etc/neon/neon.yaml | 3 ++ neon_iris/web_client.py | 83 +++++++++++++++++++------------ 2 files changed, 55 insertions(+), 31 deletions(-) diff --git a/docker_overlay/etc/neon/neon.yaml b/docker_overlay/etc/neon/neon.yaml index dea30dc..c65b600 100644 --- a/docker_overlay/etc/neon/neon.yaml +++ b/docker_overlay/etc/neon/neon.yaml @@ -9,6 +9,9 @@ iris: webui_title: Neon AI webui_description: Chat with Neon webui_input_placeholder: Ask me something + webui_chatbot_label: Chat History + webui_mic_label: Speak to Neon + webui_text_label: Text with Neon server_address: "0.0.0.0" server_port: 7860 default_lang: en-us diff --git a/neon_iris/web_client.py b/neon_iris/web_client.py index 480a832..fd9f991 100644 --- a/neon_iris/web_client.py +++ b/neon_iris/web_client.py @@ -27,7 +27,7 @@ from os import makedirs from os.path import isfile, join, isdir from time import time -from typing import List, Dict +from typing import List, Dict, Tuple from uuid import uuid4 import gradio @@ -42,8 +42,6 @@ from ovos_utils.xdg_utils import xdg_data_home from neon_iris.client import NeonAIClient -import librosa -import soundfile as sf class GradIOClient(NeonAIClient): @@ -53,6 +51,7 @@ def __init__(self, lang: str = None): NeonAIClient.__init__(self, config.get("MQ")) self._await_response = Event() self._response = None + self._transcribed = None self._current_tts = dict() self._profiles: Dict[str, dict] = dict() self._audio_path = join(xdg_data_home(), "iris", "stt") @@ -118,21 +117,24 @@ def update_profile(self, stt_lang: str, tts_lang: str, tts_lang_2: str, LOG.info(f"Updated profile for: {session_id}") return session_id - def on_user_input(self, utterance: str, *args, **kwargs) -> str: + def on_user_input(self, utterance: str, + chat_history: List[Tuple[str, str]], + audio_input: str, + client_session: str) -> (List[Tuple[str, str]], str, str, None): """ Callback to handle textual user input @param utterance: String utterance submitted by the user - @returns: String response from Neon (or "ERROR") + @returns: Input box contents, Updated chat history, Gradio session ID """ input_time = time() LOG.debug(f"Input received") if not self._await_response.wait(30): LOG.error("Previous response not completed after 30 seconds") - LOG.debug(f"args={args}|kwargs={kwargs}") in_queue = time() - input_time self._await_response.clear() self._response = None - gradio_id = args[2] + self._transcribed = None + gradio_id = client_session lang = self.get_lang(gradio_id) if utterance: LOG.info(f"Sending utterance: {utterance} with lang: {lang}") @@ -142,8 +144,8 @@ def on_user_input(self, utterance: str, *args, **kwargs) -> str: "timing": {"wait_in_queue": in_queue, "gradio_sent": time()}}) else: - LOG.info(f"Sending audio: {args[1]} with lang: {lang}") - self.send_audio(args[1], lang, username=gradio_id, + LOG.info(f"Sending audio: {audio_input} with lang: {lang}") + self.send_audio(audio_input, lang, username=gradio_id, user_profiles=[self._profiles[gradio_id]], context={"gradio": {"session": gradio_id}, "timing": {"wait_in_queue": in_queue, @@ -153,7 +155,12 @@ def on_user_input(self, utterance: str, *args, **kwargs) -> str: self._await_response.set() self._response = self._response or "ERROR" LOG.info(f"Got response={self._response}") - return self._response + if utterance: + chat_history.append((utterance, self._response)) + elif self._transcribed: + LOG.info(f"Got transcript: {self._transcribed}") + chat_history.append((self._transcribed, self._response)) + return chat_history, gradio_id, "", None def play_tts(self, session_id: str): LOG.info(f"Playing most recent TTS file {self._current_tts}") @@ -166,36 +173,48 @@ def run(self): self._await_response.set() title = self.config.get("webui_title", "Neon AI") description = self.config.get("webui_description", "Chat With Neon") - chatbot = self.config.get("webui_chatbot_label") or description + chatbot_label = self.config.get("webui_chatbot_label") or description speech = self.config.get("webui_mic_label") or description + text_label = self.config.get("webui_text_label") or description placeholder = self.config.get("webui_input_placeholder", "Ask me something") address = self.config.get("server_address") or "0.0.0.0" port = self.config.get("server_port") or 7860 - chatbot = gradio.Chatbot(label=chatbot) - textbox = gradio.Textbox(placeholder=placeholder) - with self.chat_ui as blocks: client_session = gradio.State(self._start_session()) client_session.attach_load_event(self._start_session, None) # Define primary UI - audio_input = gradio.Audio(source="microphone", - type="filepath", - label=speech) - gradio.ChatInterface(self.on_user_input, - chatbot=chatbot, - textbox=textbox, - additional_inputs=[audio_input, client_session], - title=title, - retry_btn=None, - undo_btn=None, ) - tts_audio = gradio.Audio(autoplay=True, visible=True, - label="Neon's Response") - tts_button = gradio.Button("Play TTS") - tts_button.click(self.play_tts, - inputs=[client_session], - outputs=[tts_audio, client_session]) + blocks.title = title + chatbot = gradio.Chatbot(label=chatbot_label) + with gradio.Row(): + textbox = gradio.Textbox(label=text_label, + placeholder=placeholder, + scale=8) + audio_input = gradio.Audio(source="microphone", + type="filepath", + label=speech, + scale=2) + submit = gradio.Button(value="Submit", + variant="primary") + submit.click(self.on_user_input, + inputs=[textbox, chatbot, audio_input, + client_session], + outputs=[chatbot, client_session, textbox, + audio_input]) + textbox.submit(self.on_user_input, + inputs=[textbox, chatbot, audio_input, + client_session], + outputs=[chatbot, client_session, textbox, + audio_input]) + with gradio.Row(): + tts_audio = gradio.Audio(autoplay=True, visible=True, + label="Neon's Response", + scale=10) + tts_button = gradio.Button("Play TTS") + tts_button.click(self.play_tts, + inputs=[client_session], + outputs=[tts_audio, client_session]) # Define settings UI with gradio.Row(): with gradio.Column(): @@ -208,7 +227,7 @@ def run(self): value=lang) tts_lang_2 = gradio.Radio(label="Second Response Language", choices=[None] + - self.supported_languages, + self.supported_languages, value=None) with gradio.Column(): time_format = gradio.Radio(label="Time Format", @@ -284,6 +303,8 @@ def handle_api_response(self, message: Message): @param message: Response message to something emitted by this client """ LOG.debug(f"Got {message.msg_type}: {message.data}") + if message.msg_type == "neon.audio_input.response": + self._transcribed = message.data.get("transcripts", [""])[0] def _handle_profile_update(self, message: Message): updated_profile = message.data["profile"] From 86665bebbe894901fc5f38f34d3a4df410443ebc Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Tue, 21 Nov 2023 17:02:59 +0000 Subject: [PATCH 29/40] Increment Version to 0.0.5a13 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index b47f401..248e686 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a12" +__version__ = "0.0.5a13" From 45aea9c273f086a284550fd89be391cca4b0262a Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Wed, 22 Nov 2023 09:04:59 -0800 Subject: [PATCH 30/40] Add audio responses to chat history and auto-play them (#40) Relates to #39 Co-authored-by: Daniel McKnight --- neon_iris/web_client.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/neon_iris/web_client.py b/neon_iris/web_client.py index fd9f991..8fd9eaa 100644 --- a/neon_iris/web_client.py +++ b/neon_iris/web_client.py @@ -120,11 +120,11 @@ def update_profile(self, stt_lang: str, tts_lang: str, tts_lang_2: str, def on_user_input(self, utterance: str, chat_history: List[Tuple[str, str]], audio_input: str, - client_session: str) -> (List[Tuple[str, str]], str, str, None): + client_session: str) -> (List[Tuple[str, str]], str, str, None, str): """ Callback to handle textual user input @param utterance: String utterance submitted by the user - @returns: Input box contents, Updated chat history, Gradio session ID + @returns: Input box contents, Updated chat history, Gradio session ID, audio input, audio output """ input_time = time() LOG.debug(f"Input received") @@ -150,6 +150,7 @@ def on_user_input(self, utterance: str, context={"gradio": {"session": gradio_id}, "timing": {"wait_in_queue": in_queue, "gradio_sent": time()}}) + chat_history.append((audio_input, None)) if not self._await_response.wait(30): LOG.error("No response received after 30s") self._await_response.set() @@ -157,14 +158,15 @@ def on_user_input(self, utterance: str, LOG.info(f"Got response={self._response}") if utterance: chat_history.append((utterance, self._response)) - elif self._transcribed: + elif isinstance(self._transcribed, str): LOG.info(f"Got transcript: {self._transcribed}") - chat_history.append((self._transcribed, self._response)) - return chat_history, gradio_id, "", None + chat_history.append((self._transcribed, self._response)) + chat_history.append((None, (self._current_tts[gradio_id], None))) + return chat_history, gradio_id, "", None, self._current_tts[gradio_id] - def play_tts(self, session_id: str): - LOG.info(f"Playing most recent TTS file {self._current_tts}") - return self._current_tts.get(session_id), session_id + # def play_tts(self, session_id: str): + # LOG.info(f"Playing most recent TTS file {self._current_tts}") + # return self._current_tts.get(session_id), session_id def run(self): """ @@ -197,24 +199,22 @@ def run(self): scale=2) submit = gradio.Button(value="Submit", variant="primary") + tts_audio = gradio.Audio(autoplay=True, visible=False) submit.click(self.on_user_input, inputs=[textbox, chatbot, audio_input, client_session], outputs=[chatbot, client_session, textbox, - audio_input]) + audio_input, tts_audio]) textbox.submit(self.on_user_input, inputs=[textbox, chatbot, audio_input, client_session], outputs=[chatbot, client_session, textbox, - audio_input]) - with gradio.Row(): - tts_audio = gradio.Audio(autoplay=True, visible=True, - label="Neon's Response", - scale=10) - tts_button = gradio.Button("Play TTS") - tts_button.click(self.play_tts, - inputs=[client_session], - outputs=[tts_audio, client_session]) + audio_input, tts_audio]) + # with gradio.Row(): + # tts_button = gradio.Button("Play TTS") + # tts_button.click(self.play_tts, + # inputs=[client_session], + # outputs=[tts_audio, client_session]) # Define settings UI with gradio.Row(): with gradio.Column(): From c566fd931989a52caff497793eaadababd5fa72d Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Wed, 22 Nov 2023 17:05:18 +0000 Subject: [PATCH 31/40] Increment Version to 0.0.5a14 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 248e686..65f76bd 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a13" +__version__ = "0.0.5a14" From 94c87c29e2533e35d8c8031bb814b50c6a8a62e4 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Mon, 27 Nov 2023 12:47:03 -0800 Subject: [PATCH 32/40] Add `neon_should_respond` context to all emitted Messages (#42) Fix typo in audio input gradio handling Co-authored-by: Daniel McKnight --- neon_iris/client.py | 1 + neon_iris/web_client.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/neon_iris/client.py b/neon_iris/client.py index 9ce374b..639f538 100644 --- a/neon_iris/client.py +++ b/neon_iris/client.py @@ -284,6 +284,7 @@ def _build_message(self, msg_type: str, data: dict, "ident": ident or str(time()), "username": username, "user_profiles": user_profiles, + "neon_should_respond": True, "timing": {}, "mq": {"routing_key": self.uid, "message_id": self.connection.create_unique_id()} diff --git a/neon_iris/web_client.py b/neon_iris/web_client.py index 8fd9eaa..a2e3a52 100644 --- a/neon_iris/web_client.py +++ b/neon_iris/web_client.py @@ -150,7 +150,7 @@ def on_user_input(self, utterance: str, context={"gradio": {"session": gradio_id}, "timing": {"wait_in_queue": in_queue, "gradio_sent": time()}}) - chat_history.append((audio_input, None)) + chat_history.append(((audio_input, None), None)) if not self._await_response.wait(30): LOG.error("No response received after 30s") self._await_response.set() From d57a07a8eda8f685df77090ff65aac5ebbd69dc4 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Mon, 27 Nov 2023 20:47:45 +0000 Subject: [PATCH 33/40] Increment Version to 0.0.5a15 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 65f76bd..38ee24b 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a14" +__version__ = "0.0.5a15" From aa855482d6ef29545fbdb742e25e8494c0c237c7 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Thu, 7 Dec 2023 10:02:44 -0800 Subject: [PATCH 34/40] Get Language Support from Core (#37) * Add handler for Neon core profile updates Add `_languages` dict with handling of language API responses Updates default config to include languages supported in default Neon Core installation Closes #35 * Disable yet-to-be-implemented language API by default Catch MQ connection error exceptions in CLI entrypoint Override default location in Docker system config * Add configuration note RE language support * Add cli entrypoint to get languages Update Docker config to default to use language API * Update language support to use combined API Ensure default lang populates settings fields --------- Co-authored-by: Daniel McKnight --- README.md | 5 +++++ docker_overlay/etc/neon/neon.yaml | 11 +--------- neon_iris/cli.py | 8 +++++++ neon_iris/client.py | 35 ++++++++++++++++++++++++++++++- neon_iris/web_client.py | 11 ++++++---- 5 files changed, 55 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 07cf069..07a022f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ iris: webui_input_placeholder: "Chat with Neon" ``` +### Language Support +For Neon Core deployments that support language support queries via MQ, `languages` +may be removed and `enable_lang_api: True` added to configuration. This will use +the reported STT/TTS supported languages in place of any `iris` configuration. + ## Interfacing with a Diana installation The `iris` CLI includes utilities for interacting with a `Diana` backend. diff --git a/docker_overlay/etc/neon/neon.yaml b/docker_overlay/etc/neon/neon.yaml index c65b600..cdb38cb 100644 --- a/docker_overlay/etc/neon/neon.yaml +++ b/docker_overlay/etc/neon/neon.yaml @@ -15,16 +15,7 @@ iris: server_address: "0.0.0.0" server_port: 7860 default_lang: en-us - languages: - - en-us - - fr-fr - - es-es - - de-de - - it-it - - uk-ua - - nl-nl - - pt-pt - - ca-es + enable_lang_api: True location: city: diff --git a/neon_iris/cli.py b/neon_iris/cli.py index 90be32f..76353d5 100644 --- a/neon_iris/cli.py +++ b/neon_iris/cli.py @@ -140,6 +140,14 @@ def start_gradio(): click.echo("Unable to connect to MQ server") +@neon_iris_cli.command(help="Query Neon Core for supported languages") +def get_languages(): + from neon_iris.util import query_neon + _print_config() + resp = query_neon("neon.languages.get", {}) + click.echo(pformat(resp)) + + @neon_iris_cli.command(help="Transcribe an audio file") @click.option('--lang', '-l', default='en-us', help="language of input audio") diff --git a/neon_iris/client.py b/neon_iris/client.py index 639f538..a79984b 100644 --- a/neon_iris/client.py +++ b/neon_iris/client.py @@ -61,7 +61,8 @@ def __init__(self, mq_config: dict = None, config_dir: str = None): self.client_name = "unknown" self._config = mq_config or dict(Configuration()).get("MQ") self._connection = self._init_mq_connection() - + self._languages = dict() + self._language_init = Event() config_dir = config_dir or join(xdg_config_home(), "neon", "neon_iris") self._user_config = get_neon_user_config(config_dir) @@ -71,6 +72,22 @@ def __init__(self, mq_config: dict = None, config_dir: str = None): self.audio_cache_dir = join(xdg_cache_home(), "neon", "neon_iris") makedirs(self.audio_cache_dir, exist_ok=True) + config = Configuration().get("iris", {}) + + # Collect supported languages + if config.get("enable_lang_api"): + message = self._build_message("neon.languages.get", {}) + self._send_message(message) + + if self._language_init.wait(30): + LOG.debug(f"Got language support: {self._languages}") + + if not self._languages: + lang_config = config.get('languages') or [] + self._languages['stt'] = lang_config + self._languages['tts'] = lang_config + LOG.debug(f"Using supported langs configuration: {self._languages}") + @property def uid(self) -> str: """ @@ -160,6 +177,8 @@ def handle_neon_response(self, channel, method, _, body): self._handle_clear_data(message) elif message.msg_type == "klat.error": self.handle_error_response(message) + elif message.msg_type == "neon.languages.get.response": + self._handle_supported_languages(message) elif message.msg_type.endswith(".response"): self.handle_api_response(message) else: @@ -244,6 +263,14 @@ def _clear_audio_cache(): # (CACHES, PROFILE, ALL_TR, CONF_LIKES, CONF_DISLIKES, ALL_DATA, # ALL_MEDIA, ALL_UNITS, ALL_LANGUAGE + def _handle_supported_languages(self, message: Message): + self._languages = message.data + if not all((x in self._languages for x in ("stt", "tts"))): + LOG.warning(f"Language support incomplete response: {self._languages}") + self._languages['stt'].sort() + self._languages['tts'].sort() + self._language_init.set() + def send_utterance(self, utterance: str, lang: str = "en-us", username: Optional[str] = None, user_profiles: Optional[list] = None, @@ -322,6 +349,12 @@ def _send_audio(self, audio_file: str, lang: str, new_only=True)} self._send_serialized_message(serialized) + def _send_message(self, message: Message): + serialized = {"msg_type": message.msg_type, + "data": message.data, + "context": message.context} + self._send_serialized_message(serialized) + def _send_serialized_message(self, serialized: dict): try: serialized['context']['timing']['client_sent'] = time() diff --git a/neon_iris/web_client.py b/neon_iris/web_client.py index a2e3a52..60a3dc8 100644 --- a/neon_iris/web_client.py +++ b/neon_iris/web_client.py @@ -218,16 +218,19 @@ def run(self): # Define settings UI with gradio.Row(): with gradio.Column(): - lang = self.get_lang(client_session.value) + lang = self.get_lang(client_session.value).split('-')[0] stt_lang = gradio.Radio(label="Input Language", - choices=self.supported_languages, + choices=self._languages.get("stt") + or self.supported_languages, value=lang) tts_lang = gradio.Radio(label="Response Language", - choices=self.supported_languages, + choices=self._languages.get("tts") + or self.supported_languages, value=lang) tts_lang_2 = gradio.Radio(label="Second Response Language", choices=[None] + - self.supported_languages, + (self._languages.get("tts") or + self.supported_languages), value=None) with gradio.Column(): time_format = gradio.Radio(label="Time Format", From 1c08d600add772f9dc523fce8f547baa86d01957 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Thu, 7 Dec 2023 18:03:02 +0000 Subject: [PATCH 35/40] Increment Version to 0.0.5a16 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 38ee24b..8b1aac1 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a15" +__version__ = "0.0.5a16" From 3dd8b2cd1692a94375d3683d7f0f670007351340 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Mon, 11 Dec 2023 13:06:18 -0800 Subject: [PATCH 36/40] Update neon-mq-connector dependency to stable spec (#43) Co-authored-by: Daniel McKnight --- requirements/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index f8a89d2..c1de4b9 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -2,5 +2,5 @@ click~=8.0 click-default-group~=1.2 neon-utils~=1.0 pyyaml>=5.4,<7.0.0 -neon-mq-connector~=0.7,>=0.7.1a4 +neon-mq-connector~=0.7,>=0.7.1 ovos-bus-client~=0.0.3 \ No newline at end of file From bab3c21f2af8a8d7ab00c7a11f006717869e6b2b Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Mon, 11 Dec 2023 21:06:34 +0000 Subject: [PATCH 37/40] Increment Version to 0.0.5a17 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index 8b1aac1..e2f1294 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a16" +__version__ = "0.0.5a17" From 7923bad13720b5b6eb44eb671deba5cd4ea3fe04 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Mon, 11 Dec 2023 13:10:15 -0800 Subject: [PATCH 38/40] Add GitHub pre-release automation (#45) Co-authored-by: Daniel McKnight --- .github/workflows/publish_test_build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish_test_build.yml b/.github/workflows/publish_test_build.yml index 742ba53..5d9d572 100644 --- a/.github/workflows/publish_test_build.yml +++ b/.github/workflows/publish_test_build.yml @@ -15,6 +15,7 @@ jobs: with: version_file: "neon_iris/version.py" setup_py: "setup.py" + publish_prerelease: true build_and_publish_docker: needs: publish_alpha_release uses: neongeckocom/.github/.github/workflows/publish_docker.yml@master From 5e191d16f481e30bbefae81b644085224b8b3755 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Mon, 11 Dec 2023 21:10:32 +0000 Subject: [PATCH 39/40] Increment Version to 0.0.5a18 --- neon_iris/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_iris/version.py b/neon_iris/version.py index e2f1294..cfee463 100644 --- a/neon_iris/version.py +++ b/neon_iris/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.5a17" +__version__ = "0.0.5a18" From e78281c2e125b02c7221f012ae78e4ad141cf2dc Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Mon, 11 Dec 2023 14:02:47 -0800 Subject: [PATCH 40/40] Update release automation (#47) Co-authored-by: Daniel McKnight --- .github/workflows/propose_release.yml | 28 +++++++++++++++++++++++++++ .github/workflows/pull_master.yml | 14 -------------- 2 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/propose_release.yml delete mode 100644 .github/workflows/pull_master.yml diff --git a/.github/workflows/propose_release.yml b/.github/workflows/propose_release.yml new file mode 100644 index 0000000..21eea4e --- /dev/null +++ b/.github/workflows/propose_release.yml @@ -0,0 +1,28 @@ +name: Propose Stable Release +on: + workflow_dispatch: + inputs: + release_type: + type: choice + description: Release Type + options: + - patch + - minor + - major +jobs: + update_version: + uses: neongeckocom/.github/.github/workflows/propose_semver_release.yml@master + with: + branch: dev + release_type: ${{ inputs.release_type }} + update_changelog: True + version_file: "neon_iris/version.py" + pull_changes: + uses: neongeckocom/.github/.github/workflows/pull_master.yml@master + needs: update_version + with: + pr_reviewer: neonreviewers + pr_assignee: ${{ github.actor }} + pr_draft: false + pr_title: ${{ needs.update_version.outputs.version }} + pr_body: ${{ needs.update_version.outputs.changelog }} \ No newline at end of file diff --git a/.github/workflows/pull_master.yml b/.github/workflows/pull_master.yml deleted file mode 100644 index 8ab60d3..0000000 --- a/.github/workflows/pull_master.yml +++ /dev/null @@ -1,14 +0,0 @@ -# This workflow will generate a PR for changes in cert into master - -name: Pull to Master -on: - push: - branches: - - dev - -jobs: - pull_changes: - uses: neongeckocom/.github/.github/workflows/pull_master.yml@master - with: - pr_reviewer: neonreviewers - pr_assignee: neondaniel