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/license_check.yml b/.github/workflows/license_check.yml new file mode 100644 index 0000000..8757eee --- /dev/null +++ b/.github/workflows/license_check.yml @@ -0,0 +1,11 @@ +name: License Check + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + license_check: + uses: OpenVoiceOS/gh-automations/.github/workflows/license-check.yml@dev + secrets: inherit diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..aa45c3d --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,14 @@ +name: Lint + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + lint: + uses: OpenVoiceOS/gh-automations/.github/workflows/lint.yml@dev + secrets: inherit + with: + ruff: true + pre_commit: false diff --git a/.github/workflows/publish_stable.yml b/.github/workflows/publish_stable.yml index 5120025..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.11" - - 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/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index 9974fae..a27b96b 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -1,108 +1,22 @@ name: Release Alpha and Propose Stable on: + workflow_dispatch: pull_request: types: [closed] branches: [dev] jobs: publish_alpha: - if: github.event.pull_request.merged == true - uses: TigreGotico/gh-automations/.github/workflows/publish-alpha.yml@master + if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' + uses: OpenVoiceOS/gh-automations/.github/workflows/publish-alpha.yml@dev secrets: inherit with: branch: 'dev' version_file: 'ovos_solver_rivescript_plugin/version.py' - setup_py: 'setup.py' update_changelog: true publish_prerelease: true + propose_release: true changelog_max_issues: 100 - - notify: - if: github.event.pull_request.merged == true - needs: publish_alpha - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Send message to Matrix bots channel - id: matrix-chat-message - uses: fadenb/matrix-chat-message@v0.0.6 - with: - homeserver: 'matrix.org' - token: ${{ secrets.MATRIX_TOKEN }} - channel: '!WjxEKjjINpyBRPFgxl:krbel.duckdns.org' - message: | - new ${{ github.event.repository.name }} PR merged! https://github.com/${{ github.repository }}/pull/${{ github.event.number }} - - publish_pypi: - needs: publish_alpha - 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.11" - - 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: 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}} - - - propose_release: - needs: publish_alpha - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - name: Checkout dev branch - uses: actions/checkout@v3 - with: - ref: dev - - - name: Setup Python - uses: actions/setup-python@v2 - with: - python-version: '3.10' - - - name: Get version from setup.py - id: get_version - run: | - VERSION=$(python setup.py --version) - echo "VERSION=$VERSION" >> $GITHUB_ENV - - - name: Create and push new branch - run: | - git checkout -b release-${{ env.VERSION }} - git push origin release-${{ env.VERSION }} - - - name: Open Pull Request from dev to master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Variables - BRANCH_NAME="release-${{ env.VERSION }}" - BASE_BRANCH="master" - HEAD_BRANCH="release-${{ env.VERSION }}" - PR_TITLE="Release ${{ env.VERSION }}" - PR_BODY="Human review requested!" - - # Create a PR using GitHub API - curl -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: token $GITHUB_TOKEN" \ - -d "{\"title\":\"$PR_TITLE\",\"body\":\"$PR_BODY\",\"head\":\"$HEAD_BRANCH\",\"base\":\"$BASE_BRANCH\"}" \ - https://api.github.com/repos/${{ github.repository }}/pulls - + publish_pypi: true + notify_matrix: true diff --git a/CHANGELOG.md b/CHANGELOG.md index ba439cd..5e43af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,64 @@ # Changelog -## [0.0.1a1](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/tree/0.0.1a1) (2025-06-08) +## [0.1.1a1](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/tree/0.1.1a1) (2026-08-15) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/compare/0.0.0...0.0.1a1) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/compare/0.1.0a2...0.1.1a1) **Merged pull requests:** -- fix: compatibility with ovos-plugin-manager 1.X.X [\#1](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/pull/1) ([JarbasAl](https://github.com/JarbasAl)) +- fix: bot identity reflects RiveScript's own origins, not Mycroft or the demo brain [\#22](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/pull/22) ([JarbasAl](https://github.com/JarbasAl)) + +## [0.1.0a2](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/tree/0.1.0a2) (2026-08-14) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/compare/0.0.2a5...0.1.0a2) + +**Merged pull requests:** + +- Migrate to pyproject.toml and register as ChatEngine \(opm.agents.chat\) [\#18](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/pull/18) ([JarbasAl](https://github.com/JarbasAl)) + +## [0.0.2a5](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/tree/0.0.2a5) (2026-08-02) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/compare/0.0.2a4...0.0.2a5) + +**Merged pull requests:** + +- ci: migrate release workflow to shared OpenVoiceOS automations [\#16](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/pull/16) ([JarbasAl](https://github.com/JarbasAl)) + +## [0.0.2a4](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/tree/0.0.2a4) (2026-08-01) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/compare/0.0.2a3...0.0.2a4) + +**Merged pull requests:** + +- docs: rewrite README in Simplified Technical English [\#15](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/pull/15) ([JarbasAl](https://github.com/JarbasAl)) + +## [0.0.2a3](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/tree/0.0.2a3) (2025-12-19) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/compare/0.0.2a2...0.0.2a3) + +**Merged pull requests:** + +- chore\(deps\): update dependency python to 3.14 [\#6](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/pull/6) ([renovate[bot]](https://github.com/apps/renovate)) + +## [0.0.2a2](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/tree/0.0.2a2) (2025-12-18) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/compare/0.0.2a1...0.0.2a2) + +**Merged pull requests:** + +- chore: Configure Renovate [\#5](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/pull/5) ([renovate[bot]](https://github.com/apps/renovate)) + +## [0.0.2a1](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/tree/0.0.2a1) (2025-11-22) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/compare/V0.0.1...0.0.2a1) + +**Merged pull requests:** + +- Update requirements.txt [\#3](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/pull/3) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.1](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/tree/V0.0.1) (2025-06-08) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-solver-plugin-rivescript/compare/0.0.1...V0.0.1) diff --git a/README.md b/README.md index 6f18a51..efb8d48 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,16 @@ # Rivescript Chatbot - -Give Mycroft some sass with Rivescript! -Leverages the [Alice chatbot](https://www.chatbots.org/chatbot/a.l.i.c.e/) to create some fun interactions. Phrases not explicitly handled by other skills will be run by the chatbot, so nearly every interaction will have _some_ response. But be warned, Mycroft might become a bit obnoxious... +This plugin is a question solver that answers with a [Rivescript](https://www.rivescript.com/) chatbot. It uses the [Alice chatbot](https://www.chatbots.org/chatbot/a.l.i.c.e/) brain to answer phrases that no other skill handles, so almost every input gets some response. Answers can be casual and a bit sassy. + +## Examples -## Examples * "Do you like ice cream" * "Do you like dogs" * "I have a jump rope" - ## Usage -Spoken answers api with a Rivescript backend +The plugin exposes a spoken-answers API with a Rivescript backend. ```python from ovos_solver_rivescript_plugin import RivescriptSolver @@ -26,3 +24,8 @@ sentence = d.spoken_answer("Do you like ice cream", {"lang": "pt-pt"}) print(sentence) # O que queres mesmo saber? ``` + +## Related projects + +* [OpenVoiceOS/ovos-plugin-manager](https://github.com/OpenVoiceOS/ovos-plugin-manager): loads and manages solver plugins like this one. +* [OpenVoiceOS/ovos-persona-server](https://github.com/OpenVoiceOS/ovos-persona-server): runs solver plugins as chat personas. diff --git a/ovos_solver_rivescript_plugin/__init__.py b/ovos_solver_rivescript_plugin/__init__.py index 330b5eb..e829911 100644 --- a/ovos_solver_rivescript_plugin/__init__.py +++ b/ovos_solver_rivescript_plugin/__init__.py @@ -1,18 +1,45 @@ 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" os.makedirs(XDG_PATH, exist_ok=True) + # Default bot identity reflects RiveScript itself, not the upstream demo + # personality bundled in brain/en-us/begin.rive ("Aiden" from Detroit, + # Michigan - sample-brain placeholders, not creator-reflective) and not + # the Mycroft project either. RiveScript was created by Noah + # Petherbridge and first released in 2005 (originally in Perl); see + # https://www.rivescript.com/about and https://www.rivescript.com/history + # There is no sourced hometown or birthday for Petherbridge, so location/ + # city name RiveScript's own documented origin instead of the person's: + # it grew out of Chatbot::Alpha and was first written in Perl, published + # under its own root namespace on CPAN. https://www.rivescript.com/history + DEFAULT_NAME = "RiveScript" + RIVESCRIPT_BIRTH_YEAR = 2005 + DEFAULT_LOCATION = "CPAN" + DEFAULT_CITY = "the Perl programming language" + DEFAULT_MASTER = "Noah Petherbridge" + DEFAULT_WEBSITE = "rivescript.com" + def __init__(self, lang="en-us", settings=None): self.settings = settings or {} self.lang = lang @@ -26,12 +53,10 @@ def __init__(self, lang="en-us", settings=None): def load_brain(self): # secondary personal bot info - if "birthday" not in self.settings: - self.settings["birthday"] = "May 23, 2016" if "sex" not in self.settings: self.settings["sex"] = "undefined" if "master" not in self.settings: - self.settings["master"] = "skynet" + self.settings["master"] = self.DEFAULT_MASTER if "eye_color" not in self.settings: self.settings["eye_color"] = "blue" if "hair" not in self.settings: @@ -55,16 +80,19 @@ def load_brain(self): if "job" not in self.settings: self.settings["job"] = "Personal Assistant" if "website" not in self.settings: - self.settings["website"] = "openvoiceos.com" + self.settings["website"] = self.DEFAULT_WEBSITE if "pet" not in self.settings: self.settings["pet"] = "bugs" if "interests" not in self.settings: self.settings["interests"] = "I am interested in all kinds of " \ "things. We can talk about anything." + if "location" not in self.settings: + self.settings["location"] = self.DEFAULT_LOCATION + if "city" not in self.settings: + self.settings["city"] = self.DEFAULT_CITY self.rs.load_directory(self.brain_path) self.rs.sort_replies() - self.rs.set_variable("birthday", self.settings["birthday"]) self.rs.set_variable("sex", self.settings["sex"]) self.rs.set_variable("eyes", self.settings["eye_color"]) self.rs.set_variable("hair", self.settings["hair"]) @@ -81,14 +109,18 @@ def load_brain(self): self.rs.set_variable("website", self.settings["website"]) self.rs.set_variable("master", self.settings["master"]) self.rs.set_variable("interests", self.settings["interests"]) - self.rs.set_variable("name", self.settings.get("name", "mycroft")) + self.rs.set_variable("name", self.settings.get("name", self.DEFAULT_NAME)) + self.rs.set_variable("location", self.settings["location"]) + self.rs.set_variable("city", self.settings["city"]) - self.rs.set_variable("age", str(date.today().year - 2016)) - # TODO - location from mycroft.conf - # self.rs.set_variable("location", - # self.location["city"]["state"]["country"][ - # "name"]) - # self.rs.set_variable("city", self.location_pretty) + try: + birth_year = int(self.settings.get("birth_year", self.RIVESCRIPT_BIRTH_YEAR)) + except (TypeError, ValueError) as e: + LOG.warning(f"Invalid birth_year in config ({e}); " + f"falling back to {self.RIVESCRIPT_BIRTH_YEAR}") + birth_year = self.RIVESCRIPT_BIRTH_YEAR + age = self.settings.get("age", str(date.today().year - birth_year)) + self.rs.set_variable("age", str(age)) def ask_brain(self, utterance): try: @@ -124,7 +156,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 76c4342..cc79e14 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_MINOR = 1 VERSION_BUILD = 1 -VERSION_ALPHA = 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/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 51cd62f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -rivescript -ovos-plugin-manager>=0.0.26,<2.0.0 \ No newline at end of file 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_bot_identity.py b/test/test_bot_identity.py new file mode 100644 index 0000000..3fee9f4 --- /dev/null +++ b/test/test_bot_identity.py @@ -0,0 +1,101 @@ +"""Regression tests: bot identity must reflect the RiveScript lineage, not +Mycroft or the upstream demo persona, and must be configurable. + +This plugin is not Mycroft and OVOS does not carry Mycroft attribution, so +the RiveScript identity variables must never default to Mycroft's identity, +and must never default to the upstream RiveScript demo personality bundled +in brain/en-us/begin.rive either ("Aiden" from Detroit, Michigan). Every +identity variable must be overridable via config. +""" +import unittest +from datetime import date + +from ovos_plugin_manager.templates.agents import AgentMessage, MessageRole + +from ovos_solver_rivescript_plugin import RivescriptBot, RivescriptChatEngine + + +class TestBotIdentity(unittest.TestCase): + def test_default_name_is_not_mycroft(self): + bot = RivescriptBot() + bot.load_brain() + name = bot.rs.get_variable("name") + self.assertNotEqual(name.lower(), "mycroft") + self.assertEqual(name, RivescriptBot.DEFAULT_NAME) + + def test_default_age_derives_from_rivescript_birth_year_not_mycroft(self): + bot = RivescriptBot() + bot.load_brain() + expected = str(date.today().year - RivescriptBot.RIVESCRIPT_BIRTH_YEAR) + self.assertEqual(bot.rs.get_variable("age"), expected) + + def test_default_master_is_not_mycroft_or_skynet(self): + bot = RivescriptBot() + bot.load_brain() + master = bot.rs.get_variable("master") + self.assertNotEqual(master.lower(), "mycroft") + self.assertNotEqual(master.lower(), "skynet") + self.assertEqual(master, RivescriptBot.DEFAULT_MASTER) + + def test_configured_name_reaches_the_answer(self): + engine = RivescriptChatEngine({"lang": "en-us", "name": "Zorb"}) + self.assertEqual(engine.brain.rs.get_variable("name"), "Zorb") + reply = engine.continue_chat( + [AgentMessage(role=MessageRole.USER, content="what is your name")] + ) + self.assertIn("zorb", reply.content.lower()) + + def test_default_location_and_city_are_not_the_upstream_demo(self): + # brain/en-us/begin.rive hardcodes "! var location = Michigan" and + # "! var city = Detroit" (the upstream RiveScript demo persona, + # "Aiden"). Both must be overridden, not left to leak through. + bot = RivescriptBot() + bot.load_brain() + self.assertNotEqual(bot.rs.get_variable("location"), "Michigan") + self.assertNotEqual(bot.rs.get_variable("city"), "Detroit") + self.assertEqual(bot.rs.get_variable("location"), RivescriptBot.DEFAULT_LOCATION) + self.assertEqual(bot.rs.get_variable("city"), RivescriptBot.DEFAULT_CITY) + + def test_default_location_and_city_name_the_language_not_the_cloud(self): + # location/city name RiveScript's own documented origin (Perl/CPAN, + # per https://www.rivescript.com/history), a fact about the + # language, not a bland placeholder and not a guess about + # Petherbridge's personal whereabouts. + bot = RivescriptBot() + bot.load_brain() + self.assertEqual(bot.rs.get_variable("location"), "CPAN") + self.assertEqual(bot.rs.get_variable("city"), "the Perl programming language") + + def test_configured_location_reaches_the_answer(self): + engine = RivescriptChatEngine({"lang": "en-us", "location": "Lisbon"}) + reply = engine.continue_chat( + [AgentMessage(role=MessageRole.USER, content="where are you from")] + ) + self.assertIn("lisbon", reply.content.lower()) + self.assertNotIn("michigan", reply.content.lower()) + + def test_configured_city_reaches_the_answer(self): + engine = RivescriptChatEngine({"lang": "en-us", "city": "Lisbon"}) + reply = engine.continue_chat( + [AgentMessage(role=MessageRole.USER, content="what city are you from")] + ) + self.assertIn("lisbon", reply.content.lower()) + self.assertNotIn("detroit", reply.content.lower()) + + def test_no_dead_birthday_constant(self): + # DEFAULT_BIRTHDAY was removed: nothing in the bundled corpus reads + # , so a constant feeding it would reach no answer. + self.assertFalse(hasattr(RivescriptBot, "DEFAULT_BIRTHDAY")) + + def test_invalid_birth_year_does_not_crash_construction(self): + # A bad config value here must degrade with a warning, not raise - + # QuestionSolversService.load_plugins has no try/except around + # plugin construction, so an uncaught ValueError here takes down the + # whole Persona, not just this handler. + engine = RivescriptChatEngine({"lang": "en-us", "birth_year": "not-a-year"}) + expected = str(date.today().year - RivescriptBot.RIVESCRIPT_BIRTH_YEAR) + self.assertEqual(engine.brain.rs.get_variable("age"), expected) + + +if __name__ == "__main__": + unittest.main() 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()