From cf7ae70192e9a9c9a1f4f9a0039626509db46e2a Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Fri, 14 Aug 2026 01:40:36 +0100 Subject: [PATCH] feat: migrate to pyproject.toml and register as opm.agents.chat Move packaging from setup.py to pyproject.toml, matching the sibling ovos-solver-plugin-aiml layout, and register the plugin under the modern opm.agents.chat entry point group by porting RiveScript to ChatEngine. The legacy neon.plugin.solver entry point is kept pointing at the original RivescriptSolver class, so nothing that still looks there breaks. Co-Authored-By: Claude Opus 5 --- .github/workflows/build-tests.yml | 14 +++++ .github/workflows/publish_stable.yml | 77 +++++------------------ ovos_solver_rivescript_plugin/__init__.py | 54 +++++++++++++++- ovos_solver_rivescript_plugin/version.py | 8 ++- pyproject.toml | 46 ++++++++++++++ requirements.txt | 2 - setup.py | 61 ------------------ test/test_plugin.py | 62 ++++++++++++++++++ 8 files changed, 194 insertions(+), 130 deletions(-) create mode 100644 .github/workflows/build-tests.yml create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100755 setup.py create mode 100644 test/test_plugin.py diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml new file mode 100644 index 0000000..95c6f69 --- /dev/null +++ b/.github/workflows/build-tests.yml @@ -0,0 +1,14 @@ +name: Build Tests + +on: + pull_request: + branches: [dev, master] + workflow_dispatch: + +jobs: + build: + uses: OpenVoiceOS/gh-automations/.github/workflows/build-tests.yml@dev + with: + python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]' + install_extras: 'test' + test_path: 'test' diff --git a/.github/workflows/publish_stable.yml b/.github/workflows/publish_stable.yml index 32010b4..5e43940 100644 --- a/.github/workflows/publish_stable.yml +++ b/.github/workflows/publish_stable.yml @@ -1,72 +1,23 @@ -name: Stable Release +name: Publish Stable Release + on: + workflow_dispatch: push: branches: [master] - workflow_dispatch: + +permissions: + contents: write jobs: publish_stable: - uses: TigreGotico/gh-automations/.github/workflows/publish-stable.yml@master - secrets: inherit + if: github.actor != 'github-actions[bot]' + uses: OpenVoiceOS/gh-automations/.github/workflows/publish-stable.yml@dev + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + MATRIX_TOKEN: ${{ secrets.MATRIX_TOKEN }} with: - branch: 'master' version_file: 'ovos_solver_rivescript_plugin/version.py' - setup_py: 'setup.py' + publish_pypi: true publish_release: true - - publish_pypi: - needs: publish_stable - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - ref: dev - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: "3.14" - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: version - run: echo "::set-output name=version::$(python setup.py --version)" - id: version - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token - with: - tag_name: V${{ steps.version.outputs.version }} - release_name: Release ${{ steps.version.outputs.version }} - body: | - Changes in this Release - ${{ steps.changelog.outputs.changelog }} - draft: false - prerelease: true - commitish: dev - - name: Build Distribution Packages - run: | - python setup.py sdist bdist_wheel - - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} - - - sync_dev: - needs: publish_stable - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - ref: master - - name: Push master -> dev - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: dev \ No newline at end of file + sync_dev: true + notify_matrix: true diff --git a/ovos_solver_rivescript_plugin/__init__.py b/ovos_solver_rivescript_plugin/__init__.py index 330b5eb..d5c8cff 100644 --- a/ovos_solver_rivescript_plugin/__init__.py +++ b/ovos_solver_rivescript_plugin/__init__.py @@ -1,13 +1,23 @@ import os from datetime import date from os.path import dirname, isdir -from typing import Optional +from typing import List, Optional from ovos_plugin_manager.templates.solvers import QuestionSolver from ovos_utils.log import LOG from ovos_utils.xdg_utils import xdg_data_home from rivescript import RiveScript +try: + from ovos_plugin_manager.templates.agents import ChatEngine, AgentMessage, MessageRole +except ImportError: + # ovos-plugin-manager < 2.2.3a1 does not ship the agents module yet. + # The legacy QuestionSolver below still works without it; only the + # ChatEngine registration is unavailable on such an old install. + ChatEngine = object + AgentMessage = None + MessageRole = None + class RivescriptBot: XDG_PATH = f"{xdg_data_home()}/rivescript" @@ -124,7 +134,49 @@ def get_spoken_answer(self, query: str, return self.brain.ask_brain(query) +class RivescriptChatEngine(ChatEngine): + """RiveScript chatbot exposed as a modern ChatEngine agent plugin. + + RiveScript is a pattern-matching chatbot: it has no notion of tool + calling, so ``tools`` is accepted (callers pass it by keyword) and + ignored, and ``supports_tools`` stays at the base default of False. + """ + + def __init__(self, config=None): + config = config or {"lang": "en-us"} + lang = config.get("lang") or "en-us" + if lang != "en-us" and lang not in os.listdir(RivescriptBot.XDG_PATH): + config["lang"] = lang = "en-us" + super().__init__(config) + self.brain = RivescriptBot(lang, self.config) + self.brain.load_brain() + + def continue_chat(self, messages: List["AgentMessage"], + session_id: str = "default", + lang: Optional[str] = None, + units: Optional[str] = None, + tools=None) -> "AgentMessage": + """ + Answer the latest user message via the RiveScript brain. + + RiveScript itself has no concept of chat history beyond the single + reply it is asked for, so only the most recent user message is used; + earlier turns in ``messages`` are ignored, same as upstream RiveScript + usage elsewhere in this plugin. + """ + query = next((m.content for m in reversed(messages) + if m.role == MessageRole.USER), "") + if not query: + return AgentMessage(role=MessageRole.ASSISTANT, content="") + answer = self.brain.ask_brain(query) or "" + return AgentMessage(role=MessageRole.ASSISTANT, content=answer) + + if __name__ == "__main__": bot = RivescriptSolver() print(bot.get_spoken_answer("hello!")) print(bot.spoken_answer("Qual é a tua comida favorita?", lang="pt-pt")) + + chat = RivescriptChatEngine() + reply = chat.continue_chat([AgentMessage(role=MessageRole.USER, content="hello!")]) + print(reply.content) diff --git a/ovos_solver_rivescript_plugin/version.py b/ovos_solver_rivescript_plugin/version.py index 643f2af..bd238c8 100644 --- a/ovos_solver_rivescript_plugin/version.py +++ b/ovos_solver_rivescript_plugin/version.py @@ -1,6 +1,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 0 -VERSION_BUILD = 2 -VERSION_ALPHA = 5 +VERSION_MINOR = 1 +VERSION_BUILD = 0 +VERSION_ALPHA = 1 # END_VERSION_BLOCK + +__version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..96ea131 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ovos-solver-rivescript-plugin" +dynamic = ["readme", "version"] +description = "A question solver plugin for ovos/neon/mycroft" +authors = [{ name = "JarbasAi", email = "jarbasai@mailfence.com" }] +license = { text = "MIT" } +requires-python = ">=3.9" +keywords = ["mycroft", "plugin", "utterance", "fallback", "query", "rivescript"] + +dependencies = [ + "rivescript", + "ovos-plugin-manager>=2.6.1a1,<3.0.0", +] + +[project.urls] +Homepage = "https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript" + +[project.optional-dependencies] +test = [ + "pytest>=7.0.0,<9", + "pytest-timeout>=2.0.0", +] + +[project.entry-points."opm.agents.chat"] +"ovos-solver-rivescript-plugin" = "ovos_solver_rivescript_plugin:RivescriptChatEngine" + +[project.entry-points."neon.plugin.solver"] +"ovos-solver-rivescript-plugin" = "ovos_solver_rivescript_plugin:RivescriptSolver" + +[tool.setuptools] +packages = ["ovos_solver_rivescript_plugin"] +include-package-data = true + +[tool.setuptools.package-data] +ovos_solver_rivescript_plugin = ["brain/**"] + +[tool.setuptools.dynamic] +readme = { file = "README.md", content-type = "text/markdown" } +version = { attr = "ovos_solver_rivescript_plugin.version.__version__" } + +[tool.pytest.ini_options] +testpaths = ["test"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 96e48ec..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -rivescript -ovos-plugin-manager>=0.0.26,<3.0.0 diff --git a/setup.py b/setup.py deleted file mode 100755 index 3f2d139..0000000 --- a/setup.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -import os -from setuptools import setup - -BASEDIR = os.path.abspath(os.path.dirname(__file__)) - - -def required(requirements_file): - """ Read requirements file and remove comments and empty lines. """ - with open(os.path.join(BASEDIR, requirements_file), 'r') as f: - requirements = f.read().splitlines() - if 'MYCROFT_LOOSE_REQUIREMENTS' in os.environ: - print('USING LOOSE REQUIREMENTS!') - requirements = [r.replace('==', '>=').replace('~=', '>=') for r in requirements] - return [pkg for pkg in requirements - if pkg.strip() and not pkg.startswith("#")] - - -with open(f"{BASEDIR}/README.md", "r") as f: - long_description = f.read() - -def get_version(): - """ Find the version of the package""" - version_file = os.path.join(BASEDIR, 'ovos_solver_rivescript_plugin', 'version.py') - major, minor, build, alpha = (None, None, None, None) - with open(version_file) as f: - for line in f: - if 'VERSION_MAJOR' in line: - major = line.split('=')[1].strip() - elif 'VERSION_MINOR' in line: - minor = line.split('=')[1].strip() - elif 'VERSION_BUILD' in line: - build = line.split('=')[1].strip() - elif 'VERSION_ALPHA' in line: - alpha = line.split('=')[1].strip() - - if ((major and minor and build and alpha) or - '# END_VERSION_BLOCK' in line): - break - version = f"{major}.{minor}.{build}" - if alpha and int(alpha) > 0: - version += f"a{alpha}" - return version - -PLUGIN_ENTRY_POINT = 'ovos-solver-rivescript-plugin=ovos_solver_rivescript_plugin:RivescriptSolver' -setup( - name='ovos-solver-rivescript-plugin', - version=get_version(), - description='A question solver plugin for ovos/neon/mycroft', - url='https://github.com/OpenVoiceOS/ovos-solver-rivescript-plugin', - author='jarbasai', - author_email='jarbasai@mailfence.com', - license='MIT', - packages=['ovos_solver_rivescript_plugin'], - zip_safe=True, - keywords='mycroft plugin utterance fallback query', - entry_points={'neon.plugin.solver': PLUGIN_ENTRY_POINT}, - install_requires=required("requirements.txt"), - long_description=long_description, - long_description_content_type='text/markdown' -) diff --git a/test/test_plugin.py b/test/test_plugin.py new file mode 100644 index 0000000..8d00c12 --- /dev/null +++ b/test/test_plugin.py @@ -0,0 +1,62 @@ +"""Smoke tests: the plugin loads and answers, and is discoverable under both +the legacy question-solver entry point and the modern chat-engine entry +point. +""" +import unittest + +from ovos_plugin_manager.templates.agents import AgentMessage, MessageRole +from ovos_plugin_manager.utils import find_plugins + +from ovos_solver_rivescript_plugin import RivescriptBot, RivescriptChatEngine, RivescriptSolver + + +class TestRivescriptSolver(unittest.TestCase): + def test_brain_answers(self): + # RivescriptSolver.__init__ hardcodes enable_tx=True, which makes the + # base QuestionSolver eagerly build a language-translation plugin at + # construction time even when no translation is ever performed. That + # is a pre-existing base-class quirk unrelated to this migration, and + # it means constructing RivescriptSolver requires a translate plugin + # to be installed. Exercise the underlying brain directly instead, + # which is what actually answers queries. + bot = RivescriptBot() + bot.load_brain() + answer = bot.ask_brain("hello") + self.assertIsInstance(answer, str) + self.assertTrue(answer) + + def test_registered_under_legacy_group(self): + plugins = find_plugins("neon.plugin.solver") + self.assertIn("ovos-solver-rivescript-plugin", plugins) + self.assertIs(plugins["ovos-solver-rivescript-plugin"], RivescriptSolver) + + +class TestRivescriptChatEngine(unittest.TestCase): + def test_continue_chat(self): + engine = RivescriptChatEngine() + reply = engine.continue_chat( + [AgentMessage(role=MessageRole.USER, content="hello")] + ) + self.assertIsInstance(reply, AgentMessage) + self.assertEqual(reply.role, MessageRole.ASSISTANT) + self.assertTrue(reply.content) + + def test_continue_chat_accepts_and_ignores_tools(self): + # ChatEngine.continue_chat callers pass tools= by keyword; a pattern + # matcher has no use for it but must still accept it without raising. + engine = RivescriptChatEngine() + reply = engine.continue_chat( + [AgentMessage(role=MessageRole.USER, content="hello")], + tools=[{"type": "function", "function": {"name": "noop"}}], + ) + self.assertIsInstance(reply, AgentMessage) + self.assertFalse(engine.supports_tools) + + def test_registered_under_chat_group(self): + plugins = find_plugins("opm.agents.chat") + self.assertIn("ovos-solver-rivescript-plugin", plugins) + self.assertIs(plugins["ovos-solver-rivescript-plugin"], RivescriptChatEngine) + + +if __name__ == "__main__": + unittest.main()