From 10fef48d6a82a305969140e7921c27f897c8cb50 Mon Sep 17 00:00:00 2001 From: JarbasAl Date: Fri, 3 Apr 2026 16:13:37 +0000 Subject: [PATCH 01/38] Increment Version to 1.0.10a1 --- ovos_adapt/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 2d1ec06..203b1a5 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,6 +1,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 VERSION_MINOR = 0 -VERSION_BUILD = 9 -VERSION_ALPHA = 0 +VERSION_BUILD = 10 +VERSION_ALPHA = 1 # END_VERSION_BLOCK From 864660ed0c12457b093f7d31e1e92edf81f7486d Mon Sep 17 00:00:00 2001 From: JarbasAl Date: Fri, 3 Apr 2026 16:13:59 +0000 Subject: [PATCH 02/38] Update Changelog --- CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe8ee6..3b42514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,5 @@ # Changelog -## [1.0.9a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.0.9a1) (2025-11-05) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.0.8...1.0.9a1) - -**Merged pull requests:** - -- Update requirements.txt [\#28](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/28) ([JarbasAl](https://github.com/JarbasAl)) - \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* From 4b8f332c57b0f3a75d57b41b820d2b32dbec68c2 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:11:29 +0100 Subject: [PATCH 03/38] Merge pull request #32 from OpenVoiceOS/update/ovos_adapt_parser/ovos-workshop-9 chore(ovos_adapt_parser): allow ovos-workshop<9.0.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f17de53..61a76ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ six>=1.10.0 ovos-plugin-manager>=0.5.0,<3.0.0 -ovos-workshop>=0.1.7,<8.0.0 +ovos-workshop>=0.1.7,<9.0.0 ovos-utils>=0.3.4,<1.0.0 langcodes From e1a68c9b4dc5c94a8b1515ae8b3567d54b8f9ff9 Mon Sep 17 00:00:00 2001 From: JarbasAl Date: Thu, 9 Apr 2026 17:23:49 +0000 Subject: [PATCH 04/38] Increment Version to 1.0.10a2 --- ovos_adapt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 203b1a5..fb81aa6 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -2,5 +2,5 @@ VERSION_MAJOR = 1 VERSION_MINOR = 0 VERSION_BUILD = 10 -VERSION_ALPHA = 1 +VERSION_ALPHA = 2 # END_VERSION_BLOCK From 9fb81ef32660b6b98dddf573d18e5896736011e0 Mon Sep 17 00:00:00 2001 From: JarbasAl Date: Thu, 9 Apr 2026 17:28:26 +0000 Subject: [PATCH 05/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b42514..c49305f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.0.10a2](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.0.10a2) (2026-04-09) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.0.9...1.0.10a2) + +**Merged pull requests:** + +- chore\(ovos\_adapt\_parser\): allow ovos-workshop\<9.0.0 [\#32](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/32) ([JarbasAl](https://github.com/JarbasAl)) + \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* From aec335e68fc6cb3cb12e70fc4db8c011492c726f Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 15 May 2026 00:02:54 +0100 Subject: [PATCH 06/38] feat(test): ovoscope end-to-end tests for AdaptPipeline (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(test): ovoscope end-to-end tests for AdaptPipeline Adds an end-to-end test suite for AdaptPipeline using ovoscope's E2EPipelineHarness. Covers: - vocab + IntentBuilder registration and required-keyword matching - missing-keyword no-match and no-intent no-match - best-of-multiple-intent selection - optional slots (present / absent) - detach_intent and detach_skill isolation - session blacklisted_intents / blacklisted_skills CI job uses the shared OpenVoiceOS/gh-automations reusable workflow with require_adapt: true and pre_release: true (the latter is temporary until ovoscope ships a release containing the E2EPipelineHarness). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 * fix(test): avoid Adapt stop-word filtering in test_utterance_field_preserved Replace "on"/"turn on the lights" with "enable"/"enable the lights" so Adapt does not silently drop the keyword as a stop word, causing the send_and_capture to return None. Co-Authored-By: claude-sonnet-4-6 * ci: modernize legacy workflows so PR checks actually run - Delete build_tests.yml and install_tests.yml: both reference mycroft-core-style extras (`[audio-backend,mark1,stt,tts,skills,gui, bus,all]`, `tflite_runtime`) that have no relevance to this library and have been failing for as long as Python 3.8 has been removed from GitHub runners. - unit_tests.yml: bump python matrix from [3.7,3.8,3.9,'3.10'] to ['3.10','3.11','3.12'] (3.7/3.8 are no longer available); upgrade actions/checkout v2 -> v6 and setup-python v2 -> v6. - license_tests.yml: same actions bumps; python 3.8 -> 3.11. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 * ci(license): upgrade action and drop 'Error' from fail list pilosus/action-pip-license-checker@v0.5.0 misclassifies modern packages (build, click, packaging, urllib3, etc.) as `Error` because it does not understand the current PEP 639/SPDX license metadata format. Upgrading to v2.6.2 fixes the classifier; dropping `Error` from the fail list prevents false positives when a single transitive dep can't be parsed. Copyleft and Other still fail — those are the categories the check is actually meant to catch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 * ci(license): pin action to v3.1.0 (v2.6.2 doesn't exist) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 * ci: migrate to pyproject.toml + apply shared gh-automations workflows Drop the legacy setup.py / requirements.txt / MANIFEST.in in favour of a single pyproject.toml. Append the __version__ assignment to ovos_adapt/version.py so setuptools' attr-based dynamic versioning can read it. Replace the legacy mycroft-style PR workflows with the standard OpenVoiceOS/gh-automations reusable workflows: build-tests.yml coverage.yml license_check.yml lint.yml pip_audit.yml release-preview.yml repo-health.yml opm-check.yml conventional-label.yml publish_stable.yml release_workflow.yml Removed: unit_tests.yml, license_tests.yml (replaced by build-tests.yml and license_check.yml from gh-automations). build_tests.yml and install_tests.yml had already been deleted earlier in this branch as mycroft-core cruft. opm-check.yml uses plugin_type=pipeline (and drops the buggy nested- quote entry_point input). ovoscope.yml now uses install_extras=test now that pyproject.toml declares a [test] optional-dependencies group. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .github/workflows/build-tests.yml | 14 ++ .github/workflows/build_tests.yml | 36 --- ...onal-label.yaml => conventional-label.yml} | 2 +- .github/workflows/coverage.yml | 16 ++ .github/workflows/install_tests.yml | 34 --- .github/workflows/license_check.yml | 10 + .github/workflows/license_tests.yml | 44 ---- .github/workflows/lint.yml | 13 ++ .github/workflows/opm-check.yml | 17 ++ .github/workflows/ovoscope.yml | 21 ++ .github/workflows/pip_audit.yml | 10 + .github/workflows/publish_stable.yml | 65 ++---- .github/workflows/release-preview.yml | 13 ++ .github/workflows/release_workflow.yml | 106 ++------- .github/workflows/repo-health.yml | 12 + .github/workflows/unit_tests.yml | 68 ------ MANIFEST.in | 3 - ovos_adapt/version.py | 2 + pyproject.toml | 47 ++++ requirements.txt | 5 - setup.py | 95 -------- test/test_ovoscope_e2e.py | 220 ++++++++++++++++++ 22 files changed, 424 insertions(+), 429 deletions(-) create mode 100644 .github/workflows/build-tests.yml delete mode 100644 .github/workflows/build_tests.yml rename .github/workflows/{conventional-label.yaml => conventional-label.yml} (77%) create mode 100644 .github/workflows/coverage.yml delete mode 100644 .github/workflows/install_tests.yml create mode 100644 .github/workflows/license_check.yml delete mode 100644 .github/workflows/license_tests.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/opm-check.yml create mode 100644 .github/workflows/ovoscope.yml create mode 100644 .github/workflows/pip_audit.yml create mode 100644 .github/workflows/release-preview.yml create mode 100644 .github/workflows/repo-health.yml delete mode 100644 .github/workflows/unit_tests.yml delete mode 100644 MANIFEST.in create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py create mode 100644 test/test_ovoscope_e2e.py diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml new file mode 100644 index 0000000..dff64be --- /dev/null +++ b/.github/workflows/build-tests.yml @@ -0,0 +1,14 @@ +name: Build Tests + +on: + pull_request: + branches: [dev, master, main] + 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/build_tests.yml b/.github/workflows/build_tests.yml deleted file mode 100644 index f204bb7..0000000 --- a/.github/workflows/build_tests.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Run Build Tests -on: - push: - workflow_dispatch: - -jobs: - build_tests: - 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: Install System Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev swig libssl-dev libfann-dev portaudio19-dev libpulse-dev - - name: Build Source Packages - run: | - python setup.py sdist - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Install tflite_runtime workaround tflit bug - run: | - pip3 install numpy - pip3 install --extra-index-url https://google-coral.github.io/py-repo/ tflite_runtime - - name: Install core repo - run: | - pip install .[audio-backend,mark1,stt,tts,skills_minimal,skills,gui,bus,all] diff --git a/.github/workflows/conventional-label.yaml b/.github/workflows/conventional-label.yml similarity index 77% rename from .github/workflows/conventional-label.yaml rename to .github/workflows/conventional-label.yml index 0a449cb..9894c1b 100644 --- a/.github/workflows/conventional-label.yaml +++ b/.github/workflows/conventional-label.yml @@ -7,4 +7,4 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: bcoe/conventional-release-labels@v1 \ No newline at end of file + - uses: bcoe/conventional-release-labels@v1 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..ce518ce --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,16 @@ +name: Code Coverage + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + coverage: + uses: OpenVoiceOS/gh-automations/.github/workflows/coverage.yml@dev + with: + python_version: '3.11' + coverage_source: 'ovos_adapt_parser' + test_path: 'test/' + install_extras: '' + min_coverage: 0 diff --git a/.github/workflows/install_tests.yml b/.github/workflows/install_tests.yml deleted file mode 100644 index 4aaabea..0000000 --- a/.github/workflows/install_tests.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Run Install Tests -on: - push: - branches: - - master - - dev - workflow_dispatch: - -jobs: - install: - strategy: - max-parallel: 2 - matrix: - python-version: [ 3.7, 3.8, 3.9, "3.10" ] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: Install System Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev swig libssl-dev - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Install package - run: | - pip install .[all] \ No newline at end of file diff --git a/.github/workflows/license_check.yml b/.github/workflows/license_check.yml new file mode 100644 index 0000000..214edaa --- /dev/null +++ b/.github/workflows/license_check.yml @@ -0,0 +1,10 @@ +name: License Check + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + license_check: + uses: OpenVoiceOS/gh-automations/.github/workflows/license-check.yml@dev diff --git a/.github/workflows/license_tests.yml b/.github/workflows/license_tests.yml deleted file mode 100644 index 29f4063..0000000 --- a/.github/workflows/license_tests.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Run License Tests -on: - push: - branches: - - master - pull_request: - branches: - - dev - workflow_dispatch: - -jobs: - license_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: Install System Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev swig libssl-dev - - name: Install core repo - run: | - pip install . - - name: Get explicit and transitive dependencies - run: | - pip freeze > requirements-all.txt - - name: Check python - id: license_check_report - uses: pilosus/action-pip-license-checker@v0.5.0 - with: - requirements: 'requirements-all.txt' - fail: 'Copyleft,Other,Error' - fails-only: true - exclude: '^(tqdm).*' - exclude-license: '^(Mozilla).*$' - - name: Print report - if: ${{ always() }} - run: echo "${{ steps.license_check_report.outputs.report }}" \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..0cb9564 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,13 @@ +name: Lint + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + lint: + uses: OpenVoiceOS/gh-automations/.github/workflows/lint.yml@dev + with: + ruff: true + pre_commit: false # set true if .pre-commit-config.yaml exists diff --git a/.github/workflows/opm-check.yml b/.github/workflows/opm-check.yml new file mode 100644 index 0000000..1b54137 --- /dev/null +++ b/.github/workflows/opm-check.yml @@ -0,0 +1,17 @@ +name: OPM Plugin Check + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + opm_check: + uses: OpenVoiceOS/gh-automations/.github/workflows/opm-check.yml@dev + with: + python_version: '3.11' + plugin_type: 'pipeline' + opm_require_found: true + opm_validate_interface: true + opm_test_import: true + opm_perf_threshold_ms: 500 diff --git a/.github/workflows/ovoscope.yml b/.github/workflows/ovoscope.yml new file mode 100644 index 0000000..7ae7c09 --- /dev/null +++ b/.github/workflows/ovoscope.yml @@ -0,0 +1,21 @@ +name: E2E Tests (ovoscope) + +on: + pull_request: + branches: [dev] + paths: + - 'ovos_adapt/**' + - 'test/test_ovoscope_e2e.py' + - '.github/workflows/ovoscope.yml' + workflow_dispatch: + +jobs: + e2e: + uses: OpenVoiceOS/gh-automations/.github/workflows/ovoscope.yml@dev + with: + install_extras: "test" + test_path: "test/test_ovoscope_e2e.py" + require_adapt: true + # Validate against the unreleased ovoscope dev branch (E2EPipelineHarness). + # Drop once ovoscope is released to PyPI with the harness. + pre_release: true diff --git a/.github/workflows/pip_audit.yml b/.github/workflows/pip_audit.yml new file mode 100644 index 0000000..131320d --- /dev/null +++ b/.github/workflows/pip_audit.yml @@ -0,0 +1,10 @@ +name: PIP Audit + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + pip_audit: + uses: OpenVoiceOS/gh-automations/.github/workflows/pip-audit.yml@dev diff --git a/.github/workflows/publish_stable.yml b/.github/workflows/publish_stable.yml index c30153f..3c47cd0 100644 --- a/.github/workflows/publish_stable.yml +++ b/.github/workflows/publish_stable.yml @@ -1,58 +1,23 @@ -name: Stable Release +name: Publish Stable Release + on: - push: - branches: [master] workflow_dispatch: + push: + branches: [master, main] + +permissions: + contents: write # required for version bump commit and release tag 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_adapt/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@v4 - with: - ref: master - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v5 - 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 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@v4 - 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 + sync_dev: true + notify_matrix: true diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml new file mode 100644 index 0000000..48ef081 --- /dev/null +++ b/.github/workflows/release-preview.yml @@ -0,0 +1,13 @@ +name: Release Preview + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + release_preview: + uses: OpenVoiceOS/gh-automations/.github/workflows/release-preview.yml@dev + with: + package_name: 'ovos_adapt_parser' + version_file: 'ovos_adapt/version.py' diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index cd9df21..6ca5af4 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -6,103 +6,23 @@ on: types: [closed] branches: [dev] +permissions: + contents: write + pull-requests: write + jobs: publish_alpha: - uses: TigreGotico/gh-automations/.github/workflows/publish-alpha.yml@master - secrets: inherit + if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' + uses: OpenVoiceOS/gh-automations/.github/workflows/publish-alpha.yml@dev + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + MATRIX_TOKEN: ${{ secrets.MATRIX_TOKEN }} with: branch: 'dev' version_file: 'ovos_adapt/version.py' - setup_py: 'setup.py' update_changelog: true publish_prerelease: true - changelog_max_issues: 100 - - notify: - if: github.event.pull_request.merged == true - needs: publish_alpha - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - 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@v4 - with: - ref: dev - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v5 - 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 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@v4 - with: - ref: dev - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - 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 - + propose_release: true + changelog_max_issues: 50 + publish_pypi: true + notify_matrix: true diff --git a/.github/workflows/repo-health.yml b/.github/workflows/repo-health.yml new file mode 100644 index 0000000..d4b4b45 --- /dev/null +++ b/.github/workflows/repo-health.yml @@ -0,0 +1,12 @@ +name: Repo Health + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + repo_health: + uses: OpenVoiceOS/gh-automations/.github/workflows/repo-health.yml@dev + with: + version_file: 'ovos_adapt/version.py' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml deleted file mode 100644 index a65c886..0000000 --- a/.github/workflows/unit_tests.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Run UnitTests -on: - pull_request: - branches: - - dev - paths-ignore: - - 'ovos_bus_client/version.py' - - 'examples/**' - - '.github/**' - - '.gitignore' - - 'LICENSE' - - 'CHANGELOG.md' - - 'MANIFEST.in' - - 'README.md' - - 'scripts/**' - push: - branches: - - master - paths-ignore: - - 'ovos_bus_client/version.py' - - 'requirements/**' - - 'examples/**' - - '.github/**' - - '.gitignore' - - 'LICENSE' - - 'CHANGELOG.md' - - 'MANIFEST.in' - - 'README.md' - - 'scripts/**' - workflow_dispatch: - -jobs: - unit_tests: - strategy: - matrix: - python-version: [ 3.7, 3.8, 3.9, '3.10'] - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v2 - - name: Set up python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install System Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev swig - python -m pip install build wheel - - name: Install repo - run: | - pip install -e . - - name: Install test dependencies - run: | - pip install -r test/requirements.txt - - name: Run unittests - run: | - pytest --cov=ovos_adapt --cov-report=xml ./test - # NOTE: additional pytest invocations should also add the --cov-append flag - # or they will overwrite previous invocations' coverage reports - # (for an example, see OVOS Skill Manager's workflow) - - name: Upload coverage - if: "${{ matrix.python-version == '3.9' }}" - uses: codecov/codecov-action@v3 - with: - token: ${{secrets.CODECOV_TOKEN}} - files: coverage.xml - verbose: true \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 58989f9..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -README.md -LICENSE.md -requirements.txt diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index fb81aa6..188cdf5 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -4,3 +4,5 @@ VERSION_BUILD = 10 VERSION_ALPHA = 2 # 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..ea1b3b2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ovos_adapt_parser" +dynamic = ["version"] +description = "A text-to-intent parsing framework." +readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE.md"] +authors = [{ name = "Sean Fitzgerald" }] +keywords = ["natural language processing", "intent", "nlp", "ovos"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Text Processing :: Linguistic", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.10" +dependencies = [ + "six>=1.10.0", + "ovos-plugin-manager>=0.5.0,<3.0.0", + "ovos-workshop>=0.1.7,<9.0.0", + "ovos-utils>=0.3.4,<1.0.0", + "langcodes", +] + +[project.optional-dependencies] +test = ["pytest", "pytest-cov"] + +[project.urls] +Homepage = "https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin" + +[project.entry-points."opm.pipeline"] +"ovos-adapt-pipeline-plugin" = "ovos_adapt.opm:AdaptPipeline" + +[tool.setuptools.packages.find] +where = ["."] +include = ["ovos_adapt*"] + +[tool.setuptools.dynamic] +version = { attr = "ovos_adapt.version.__version__" } diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 61a76ff..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -six>=1.10.0 -ovos-plugin-manager>=0.5.0,<3.0.0 -ovos-workshop>=0.1.7,<9.0.0 -ovos-utils>=0.3.4,<1.0.0 -langcodes diff --git a/setup.py b/setup.py deleted file mode 100644 index fcb3bd9..0000000 --- a/setup.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright 2017 Mycroft AI Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import os -import os.path - -from setuptools import setup - -BASEDIR = os.path.abspath(os.path.dirname(__file__)) - - -def get_version(): - """ Find the version of ovos-core""" - version = None - version_file = os.path.join(BASEDIR, 'ovos_adapt', '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 int(alpha): - version += f"a{alpha}" - return version - - -with open(os.path.join(BASEDIR, "README.md"), "r") as f: - long_description = f.read() - - -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('==', '>=') for r in requirements] - return [pkg for pkg in requirements - if pkg.strip() and not pkg.startswith("#")] - - -PLUGIN_ENTRY_POINT = 'ovos-adapt-pipeline-plugin=ovos_adapt.opm:AdaptPipeline' - -setup( - name="ovos_adapt_parser", - version=get_version(), - author="Sean Fitzgerald", - description="A text-to-intent parsing framework.", - long_description=long_description, - long_description_content_type="text/markdown", - license="Apache License 2.0", - keywords="natural language processing", - entry_points={'opm.pipeline': PLUGIN_ENTRY_POINT}, - url="https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin", - packages=["ovos_adapt", - "ovos_adapt.tools", - "ovos_adapt.tools.text", - "ovos_adapt.tools.debug"], - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Topic :: Text Processing :: Linguistic', - 'License :: OSI Approved :: Apache Software License', - - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - ], - install_requires=required('requirements.txt') -) diff --git a/test/test_ovoscope_e2e.py b/test/test_ovoscope_e2e.py new file mode 100644 index 0000000..b8edd7d --- /dev/null +++ b/test/test_ovoscope_e2e.py @@ -0,0 +1,220 @@ +"""End-to-end tests for AdaptPipeline using ovoscope. + +Built on top of ovoscope's reusable :class:`E2EPipelineHarness` so this +file only contains adapt-specific concerns (vocab + IntentBuilder +registration, slot assertions). The harness handles MiniCroft startup, +Configuration save/restore, bus capture, and per-test skill detach. +""" +import unittest + +import pytest + +ovoscope = pytest.importorskip("ovoscope", reason="ovoscope not installed; skipping E2E tests") + +from ovoscope import ( # noqa: E402 + E2EPipelineHarness, + detach_intent, + detach_skill, + make_session, + register_adapt_intent, + register_adapt_vocab, +) +from ovos_workshop.intents import IntentBuilder # noqa: E402 + +from ovos_adapt.opm import AdaptPipeline # noqa: E402 + +PIPELINE_ID = "ovos-adapt-pipeline-plugin" +CONFIG_KEY = "adapt" + + +class _AdaptHarness(E2EPipelineHarness): + PIPELINE_ID = PIPELINE_ID + CONFIG_KEY = CONFIG_KEY + PLUGIN_CONFIG = {} + SKILL_ID = "test_skill_adapt" + + pipeline: AdaptPipeline # type: ignore[assignment] + + def _vocab(self, name, words): + register_adapt_vocab(self.bus, f"{self.SKILL_ID}:{name}", words) + + def _intent(self, builder): + register_adapt_intent(self.bus, builder) + + +class TestRegisteredIntentMatch(_AdaptHarness): + def test_all_required_keywords_present_fires_intent(self): + self._vocab("TurnOff", ["off", "disable", "shutdown"]) + self._vocab("Light", ["light", "lights", "lamp"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light") + ) + msg = self.send_and_capture( + "turn off the lights", expected_types=[f"{self.SKILL_ID}:lights_off"] + ) + self.assertIsNotNone(msg, "expected intent match on bus") + self.assertEqual(msg.msg_type, f"{self.SKILL_ID}:lights_off") + self.assertEqual(msg.data.get("utterance"), "turn off the lights") + + def test_missing_required_keyword_no_match(self): + self._vocab("TurnOff", ["off", "disable"]) + self._vocab("Light", ["light", "lights"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light") + ) + self.expect_no_match("turn on the lights") + + def test_no_match_when_no_intents_registered(self): + self.expect_no_match("turn off the lights") + + def test_utterance_field_preserved(self): + self._vocab("TurnOn", ["enable", "activate"]) + self._vocab("Light", ["light", "lights"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_on") + .require(f"{self.SKILL_ID}:TurnOn") + .require(f"{self.SKILL_ID}:Light") + ) + utterance = "enable the lights" + msg = self.send_and_capture(utterance, expected_types=[f"{self.SKILL_ID}:lights_on"]) + self.assertIsNotNone(msg) + self.assertEqual(msg.data.get("utterance"), utterance) + + def test_best_intent_selected_among_multiple(self): + self._vocab("TurnOff", ["off", "disable"]) + self._vocab("TurnOn", ["on", "enable"]) + self._vocab("Light", ["light", "lights"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light") + ) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_on") + .require(f"{self.SKILL_ID}:TurnOn") + .require(f"{self.SKILL_ID}:Light") + ) + msg = self.send_and_capture( + "turn on the lights", expected_types=[f"{self.SKILL_ID}:lights_on"] + ) + self.assertIsNotNone(msg) + self.assertEqual(msg.msg_type, f"{self.SKILL_ID}:lights_on") + + +class TestOptionalSlots(_AdaptHarness): + def test_optional_slot_captured_when_present(self): + self._vocab("TurnOff", ["off"]) + self._vocab("Light", ["lights"]) + self._vocab("Room", ["kitchen", "bedroom", "bathroom"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light") + .optionally(f"{self.SKILL_ID}:Room") + ) + msg = self.send_and_capture( + "turn off the bedroom lights", expected_types=[f"{self.SKILL_ID}:lights_off"] + ) + self.assertIsNotNone(msg) + + def test_optional_slot_absent_still_fires(self): + self._vocab("TurnOff", ["off"]) + self._vocab("Light", ["lights"]) + self._vocab("Room", ["kitchen", "bedroom"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light") + .optionally(f"{self.SKILL_ID}:Room") + ) + msg = self.send_and_capture( + "turn off the lights", expected_types=[f"{self.SKILL_ID}:lights_off"] + ) + self.assertIsNotNone(msg) + + +class TestDetach(_AdaptHarness): + def test_detach_intent_prevents_match(self): + self._vocab("TurnOff", ["off"]) + self._vocab("Light", ["lights"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light") + ) + msg = self.send_and_capture( + "turn off the lights", expected_types=[f"{self.SKILL_ID}:lights_off"] + ) + self.assertIsNotNone(msg) + + detach_intent(self.bus, f"{self.SKILL_ID}:lights_off") + self.expect_no_match("turn off the lights") + + def test_detach_skill_removes_all_its_intents(self): + self._vocab("TurnOff", ["off"]) + self._vocab("TurnOn", ["on"]) + self._vocab("Light", ["lights"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light") + ) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_on") + .require(f"{self.SKILL_ID}:TurnOn") + .require(f"{self.SKILL_ID}:Light") + ) + register_adapt_vocab(self.bus, "skill_b_adapt:Play", ["play"]) + register_adapt_vocab(self.bus, "skill_b_adapt:Music", ["music"]) + register_adapt_intent( + self.bus, + IntentBuilder("skill_b_adapt:play_music") + .require("skill_b_adapt:Play") + .require("skill_b_adapt:Music"), + ) + + detach_skill(self.bus, self.SKILL_ID) + + self.expect_no_match("turn off the lights") + self.expect_no_match("turn on the lights") + msg = self.send_and_capture("play music", expected_types=["skill_b_adapt:play_music"]) + self.assertIsNotNone(msg, "skill_b intent should survive skill_a detach") + detach_skill(self.bus, "skill_b_adapt") + + +class TestSessionBlacklist(_AdaptHarness): + def test_blacklisted_intent_is_skipped(self): + self._vocab("TurnOff", ["off"]) + self._vocab("Light", ["lights"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light") + ) + sess = make_session( + "bl-intent-test", + blacklisted_intents=[f"{self.SKILL_ID}:lights_off"], + ) + self.expect_no_match("turn off the lights", session=sess, timeout=3.0) + + def test_blacklisted_skill_is_skipped(self): + self._vocab("TurnOff", ["off"]) + self._vocab("Light", ["lights"]) + self._intent( + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light") + ) + sess = make_session( + "bl-skill-test", + blacklisted_skills=[self.SKILL_ID], + ) + self.expect_no_match("turn off the lights", session=sess, timeout=3.0) + + +if __name__ == "__main__": + unittest.main() From 5071db8f97dce2a6c51da28011209843767d8f50 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 14 May 2026 23:03:05 +0000 Subject: [PATCH 07/38] Increment Version to 1.1.0a1 --- ovos_adapt/version.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 188cdf5..415a70d 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,8 +1,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 -VERSION_MINOR = 0 -VERSION_BUILD = 10 -VERSION_ALPHA = 2 +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 "") From 9f264cdbd7caf37a7e7a975cfa730edd9495df3a Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 14 May 2026 23:03:31 +0000 Subject: [PATCH 08/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c49305f..ebf6102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.1.0a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.1.0a1) (2026-05-14) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.0.10a2...1.1.0a1) + +**Merged pull requests:** + +- feat\(test\): ovoscope end-to-end tests for AdaptPipeline [\#34](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/34) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.0.10a2](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.0.10a2) (2026-04-09) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.0.9...1.0.10a2) From 8208165cf2b11c2f3f86e2d63b0cabab2b9249fb Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 21 May 2026 20:57:03 +0100 Subject: [PATCH 09/38] feat: expand OVOS template syntax in vocab entries (#36) * feat: expand OVOS template syntax in vocab entries Wire ovos_utils.bracket_expansion.expand_template into the vocab registration path so a single entry like turn (on|off) the [bright] lights is expanded into all concrete surface forms before being handed to the adapt engine. This brings adapt in line with the rest of the OVOS stack which already supports (a|b) alternatives and [opt] optionals in templates. * refactor: inline bracket-expansion helper, drop ovos-utils import Replace the ovos_utils.bracket_expansion import with a local ovos_adapt._bracket_expansion module so the plugin does not need ovos-utils as a runtime dependency just for two regex helpers. * chore: remove accidentally committed AUDIT.md and egg-info * refactor: simplify expand_template dedup using set Order does not matter when feeding training data to the adapt engine; drop the seen-tracker boilerplate in favour of a plain set. * refactor: use ovos_utils.bracket_expansion instead of a local copy ovos-utils is already a dependency, so import expand_template from ovos_utils.bracket_expansion and drop the inline reimplementation. Restore pyproject.toml. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: expand OVOS template syntax in engine.register_entity Move bracket-template expansion from the OPM pipeline wrapper into IntentDeterminationEngine.register_entity so it applies to every caller, including DomainIntentDeterminationEngine and direct engine use, not just the pipeline path. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: vendor expand_template in the engine module Keep IntentDeterminationEngine standalone: port the single expand_template helper into engine.py instead of importing it from ovos-utils, which is only required by the optional opm.py pipeline entrypoint. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ovos_adapt/engine.py | 53 ++++++++++++++++++++++++++++++++++--- test/test_intent_service.py | 32 ++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/ovos_adapt/engine.py b/ovos_adapt/engine.py index 76130ea..ba20154 100644 --- a/ovos_adapt/engine.py +++ b/ovos_adapt/engine.py @@ -13,8 +13,11 @@ # limitations under the License. # +import itertools import re import heapq +from typing import List + from ovos_adapt.entity_tagger import EntityTagger from ovos_adapt.parser import Parser from ovos_adapt.tools.text.tokenizer import EnglishTokenizer @@ -23,6 +26,39 @@ __author__ = 'seanfitz' +def expand_template(template: str) -> List[str]: + """Expand OVOS template syntax into all concrete surface forms. + + ``(a|b)`` alternatives and ``[opt]`` optionals are expanded into the + full list of sentences they describe. + """ + def expand_optional(text): + return re.sub(r"\[([^\[\]]+)\]", lambda m: f"({m.group(1)}|)", text) + + def expand_alternatives(text): + parts = [] + for segment in re.split(r"(\([^\(\)]+\))", text): + if segment.startswith("(") and segment.endswith(")"): + parts.append(segment[1:-1].split("|")) + else: + parts.append([segment]) + return itertools.product(*parts) + + def fully_expand(texts): + result = set(texts) + while True: + expanded = set() + for text in result: + for option in expand_alternatives(text): + expanded.add("".join(option).strip()) + if expanded == result: + break + result = expanded + return sorted(result) + + return fully_expand([expand_optional(template)]) + + class IntentDeterminationEngine(object): """ IntentDeterminationEngine @@ -154,14 +190,25 @@ def register_entity(self, entity_value, entity_type, alias_of=None): """ Register an entity to be tagged in potential parse results + OVOS template syntax is supported in entity_value: ``(a|b)`` + alternatives and ``[opt]`` optionals are expanded into all concrete + surface forms, each registered as a separate trie entry. + Args: entity_value(str): the value/proper name of an entity instance (Ex: "The Big Bang Theory") entity_type(str): the type/tag of an entity instance (Ex: "Television Show") """ - if alias_of: - self.trie.insert(entity_value.lower(), data=(alias_of, entity_type)) + if entity_value and any(c in entity_value for c in "(["): + variants = {" ".join(v.split()) for v in expand_template(entity_value) + if v and v.strip()} else: - self.trie.insert(entity_value.lower(), data=(entity_value, entity_type)) + variants = {entity_value} + for variant in variants: + if alias_of: + self.trie.insert(variant.lower(), data=(alias_of, entity_type)) + else: + self.trie.insert(variant.lower(), data=(variant, entity_type)) + if not alias_of: self.trie.insert(entity_type.lower(), data=(entity_type, 'Concept')) def register_regex_entity(self, regex_str): diff --git a/test/test_intent_service.py b/test/test_intent_service.py index 1957d54..3c7dbab 100644 --- a/test/test_intent_service.py +++ b/test/test_intent_service.py @@ -105,6 +105,38 @@ def test_get_no_match_after_detach_skill(self): self.assertEqual(reply.data['intent'], None) +class TestBracketExpansion(TestCase): + """Verify that OVOS template syntax in vocab entries is expanded + into concrete surface forms by the adapt engine's register_entity.""" + + def setUp(self): + self.adapt_pipeline = AdaptPipeline(mock.Mock()) + # Register a single templated vocab entry covering (a|b) and [opt] + msg = create_vocab_msg('lightAction', + 'turn (on|off) the [bright] lights') + self.adapt_pipeline.handle_register_vocab(msg) + + intent = IntentBuilder('skill:lightsIntent').require('lightAction') + msg = Message('register_intent', intent.__dict__) + self.adapt_pipeline.handle_register_intent(msg) + + def _match(self, utterance): + msg = Message('intent.service.adapt.get', + data={'utterance': utterance}) + self.adapt_pipeline.handle_get_adapt(msg) + return get_last_message(self.adapt_pipeline.bus).data['intent'] + + def test_alternative_and_optional_present(self): + intent = self._match('turn off the bright lights') + self.assertIsNotNone(intent) + self.assertEqual(intent['intent_type'], 'skill:lightsIntent') + + def test_alternative_and_optional_absent(self): + intent = self._match('turn on the lights') + self.assertIsNotNone(intent) + self.assertEqual(intent['intent_type'], 'skill:lightsIntent') + + class TestAdaptIntent(TestCase): """Test the AdaptIntent wrapper.""" From 91131df17a546c19dbe187d827a36c4ea2c063eb Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 21 May 2026 19:57:14 +0000 Subject: [PATCH 10/38] Increment Version to 1.2.0a1 --- ovos_adapt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 415a70d..4b250d3 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,6 +1,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 -VERSION_MINOR = 1 +VERSION_MINOR = 2 VERSION_BUILD = 0 VERSION_ALPHA = 1 # END_VERSION_BLOCK From cff0e6ed25654ae8df72bed16bfb05732ccd7477 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 21 May 2026 19:57:32 +0000 Subject: [PATCH 11/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf6102..1d84c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.2.0a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.2.0a1) (2026-05-21) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.1.0a1...1.2.0a1) + +**Merged pull requests:** + +- feat: expand OVOS template syntax in vocab entries [\#36](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/36) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.1.0a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.1.0a1) (2026-05-14) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.0.10a2...1.1.0a1) From 05ab06a646306a63ea09a2e417f09d52bad76d26 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 22 May 2026 13:42:16 +0100 Subject: [PATCH 12/38] docs: add zero-to-hero documentation guide (#39) Add a docs/ guide covering the Adapt pipeline plugin end to end: concepts and theory for newcomers, a quickstart, the IntentBuilder reference with worked examples, configuration, the messagebus protocol, and an internals page deriving the confidence formula. Link it from the README. Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 11 +++ docs/bus-protocol.md | 87 +++++++++++++++++++++++ docs/concepts.md | 107 +++++++++++++++++++++++++++++ docs/configuration.md | 77 +++++++++++++++++++++ docs/index.md | 60 ++++++++++++++++ docs/internals.md | 126 ++++++++++++++++++++++++++++++++++ docs/quickstart.md | 87 +++++++++++++++++++++++ docs/writing-intents.md | 148 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 703 insertions(+) create mode 100644 docs/bus-protocol.md create mode 100644 docs/concepts.md create mode 100644 docs/configuration.md create mode 100644 docs/index.md create mode 100644 docs/internals.md create mode 100644 docs/quickstart.md create mode 100644 docs/writing-intents.md diff --git a/README.md b/README.md index 2c27d44..0159dfc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,17 @@ The Adapt Intent Parser is a flexible and extensible intent definition and deter This repository contains a OVOS pipeline plugin and bundles a fork of the original [adapt-parser](https://github.com/MycroftAI/adapt) from the defunct MycroftAI +Documentation +============= +A zero-to-hero guide lives in [`docs/`](docs/index.md): + +- [Concepts](docs/concepts.md) — entities, intents, cliques, and confidence +- [Quickstart](docs/quickstart.md) — install, enable, match your first utterance +- [Writing intents](docs/writing-intents.md) — the `IntentBuilder` API with examples +- [Configuration](docs/configuration.md) — confidence tiers and every config key +- [Bus protocol](docs/bus-protocol.md) — the messagebus API skills register over +- [Internals](docs/internals.md) — tagging, clique expansion, the confidence math + Examples ======== Executable examples can be found in the [examples folder](https://github.com/MycroftAI/adapt/tree/master/examples). diff --git a/docs/bus-protocol.md b/docs/bus-protocol.md new file mode 100644 index 0000000..616bbe7 --- /dev/null +++ b/docs/bus-protocol.md @@ -0,0 +1,87 @@ +# Bus protocol + +In a full OVOS install the plugin is driven entirely over the messagebus. +Skills emit registration messages; the plugin matches utterances and emits +results. This page documents that contract — useful when writing a skill +framework, debugging, or integrating a non-standard skill. + +## Registration messages (skill → plugin) + +The plugin listens for four messages: + +### `register_vocab` + +Registers one entity. `message.data`: + +| Field | Meaning | +|---|---| +| `entity_value` | the surface form, e.g. `"Tokyo"` | +| `entity_type` | the type, e.g. `"Location"` | +| `regex` | a regex string (instead of `entity_value`/`entity_type`) | +| `alias_of` | optional canonical form this value resolves to | +| `lang` | language tag; routed to that language's engine | + +A plain keyword sends `entity_value` + `entity_type`. A regex entity sends +`regex` (with a named group). One message registers one entity, so a skill +emits many. + +### `register_intent` + +Registers a built intent. The data is an intent envelope produced by +`IntentBuilder(...).build()` and serialised by `ovos_workshop.intents`. The +plugin reconstructs the parser and adds it to the engine. + +### `detach_intent` + +Removes a single intent. `message.data['intent_name']` — the intent's name. + +### `detach_skill` + +Removes every intent and vocab item belonging to a skill. +`message.data['skill_id']` — all intents whose name starts with that prefix are +dropped. Emitted when a skill unloads. + +## Query messages + +### `intent.service.adapt.get` + +Ask the plugin to match an utterance directly (debugging / introspection). The +plugin replies with the best intent or a null match. + +### `intent.service.adapt.manifest.get` + +Replies with the list of registered intents. + +### `intent.service.adapt.vocab.manifest.get` + +Replies with the list of registered vocabulary. + +## Matching flow + +The plugin does not match on `register_*`. Matching is driven by the OVOS +intent service, which calls the tier methods (`match_high`, `match_medium`, +`match_low`) as it walks the configured pipeline. Each call: + +1. takes the incoming utterance(s) for the session language, +2. skips anything longer than `max_words`, +3. runs the engine's `determine_intent`, +4. returns the best result **if** its confidence clears the tier threshold, +5. on a returned match, feeds the matched entities back into the session + context so follow-up utterances can use them. + +## Result shape + +A match is an `IntentHandlerMatch` carrying: + +- `match_type` — the intent name (`skill_id:intent_name`) +- `match_data` — the parsed intent dict: `confidence`, every matched slot keyed + by entity type, and `__tags__` (the raw tags, used for context) +- `skill_id` — the owning skill, taken from the intent-name prefix +- `utterance` — the utterance that matched + +## Sessions and context + +Each match updates the `Session` context with the entities it consumed. +A later utterance can then satisfy a `require`d slot from context instead of +from its own words — this is how follow-up commands ("...and the bedroom one +too") resolve. Context is per-session; see [Internals](internals.md). diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..230f665 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,107 @@ +# Concepts + +This page explains *how* Adapt decides which intent an utterance belongs to. +No prior knowledge is assumed. + +## The problem + +A user says *"what's the weather in Tokyo"*. The assistant must decide which +registered action — which **intent** — that sentence is asking for, and pull +out the useful values (`Tokyo`). Adapt solves this with keyword matching plus a +few combination rules. + +## Entities + +An **entity** is a named value. You register entities by listing their possible +surface forms and giving each a **type**: + +```text +type "WeatherKeyword" -> "weather", "forecast" +type "Location" -> "Seattle", "San Francisco", "Tokyo" +``` + +When Adapt sees one of those words in an utterance, it **tags** it: the word +`Tokyo` becomes a tag of type `Location`. Tagging is exact — Adapt only finds +words you registered. There is no fuzzy matching and no generalisation. + +Internally every registered surface form is stored in a **Trie** (a prefix +tree), so tagging an utterance is fast even with thousands of entities. + +## Intents + +An **intent** is an action, defined as a set of requirements over entity +*types*. You build one with `IntentBuilder`: + +```python +from ovos_adapt.intent import IntentBuilder + +weather = IntentBuilder("WeatherIntent") \ + .require("WeatherKeyword") \ + .require("Location") \ + .optionally("WeatherType") \ + .build() +``` + +This says: the `WeatherIntent` fires when the utterance contains **both** a +`WeatherKeyword` tag and a `Location` tag; a `WeatherType` tag is used if +present but is not required. + +The four rules an intent can use: + +| Rule | Meaning | +|---|---| +| `require(type)` | the intent only matches if a tag of this type is present | +| `optionally(type)` | used if present, ignored if absent — adds confidence | +| `one_of([types])` | at least one of these types must be present | +| `exclude(type)` | the intent is rejected if a tag of this type is present | + +An intent that misses any `require` (or `one_of`, or hits an `exclude`) scores +**zero** and does not fire. + +## Cliques: handling overlap + +A word can be tagged as more than one type, and two tags can cover overlapping +parts of the utterance. Adapt cannot use two overlapping tags in the same +answer, so it expands the tag list into **cliques** — maximal sets of tags that +do *not* overlap each other. Each clique is one self-consistent reading of the +utterance. Every intent is then validated against each clique. + +## Confidence + +OVOS pipelines work in confidence scores between 0 and 1. Adapt computes an +intent's confidence from three things: + +```text +confidence = intent_weight / total_tags x clique_coverage +``` + +- **intent_weight** — how many of the clique's tags this intent actually used + (its required + optional slots). More matched slots → higher weight. +- **total_tags** — how many tags the clique has in total. Tags the intent did + *not* use still divide the score, so an utterance crowded with unrelated + keywords **dilutes** every intent. +- **clique_coverage** — how much of the utterance, character for character, the + clique's tags cover. Longer matched keywords cover more and score higher. + +The practical consequences: + +- An intent that explains *more* of the utterance scores higher. +- A short keyword buried in a long sentence scores low — most of the sentence + is uncovered. +- Stray keywords from other intents lower everyone's score. + +## Confidence tiers + +OVOS runs pipeline plugins in three passes — `match_high`, `match_medium`, +`match_low`. Adapt exposes the same matching at three thresholds +(`conf_high` 0.65, `conf_med` 0.45, `conf_low` 0.25). A match is only returned +in a pass if its confidence clears that pass's threshold, so a confident Adapt +intent is matched before lower-confidence pipeline stages get a turn. See +[Configuration](configuration.md). + +## Next + +- [Quickstart](quickstart.md) — see all of this run end to end. +- [Writing intents](writing-intents.md) — the full `IntentBuilder` API with + worked examples. +- [Internals](internals.md) — the exact tagging, clique, and confidence maths. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..d61a8f9 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,77 @@ +# Configuration + +## Entry point + +The plugin is published under the OPM `opm.pipeline` entry point: + +```toml +[project.entry-points."opm.pipeline"] +"ovos-adapt-pipeline-plugin" = "ovos_adapt.opm:AdaptPipeline" +``` + +OVOS discovers it by that id — `ovos-adapt-pipeline-plugin`. + +## Enabling it in the pipeline + +`mycroft.conf`, under `intents.pipeline`, lists the matcher stages in priority +order. Each Adapt stage is referenced by id plus a confidence-tier suffix: + +```json +{ + "intents": { + "pipeline": [ + "ovos-adapt-pipeline-plugin-high", + "ovos-padatious-pipeline-plugin-high", + "ovos-adapt-pipeline-plugin-medium", + "ovos-adapt-pipeline-plugin-low" + ] + } +} +``` + +A stage earlier in the list wins ties. A common layout runs every matcher's +**high** tier first, then medium, then low, so a confident match from any +matcher beats a shaky match from the one listed first. + +## Confidence tiers + +The plugin exposes three matchers, one per tier. Each returns a match only when +its confidence clears the tier threshold: + +| Tier | Method | Default threshold | Config key | +|---|---|---|---| +| high | `match_high` | 0.65 | `conf_high` | +| medium | `match_medium` | 0.45 | `conf_med` | +| low | `match_low` | 0.25 | `conf_low` | + +The same utterance is scored once; the tier only decides which threshold the +score must clear. Lower a threshold to let weaker matches through that tier; +raise it to demand a stronger match. + +## Settings + +| Key | Default | Effect | +|---|---|---| +| `conf_high` | `0.65` | minimum confidence for a `match_high` result | +| `conf_med` | `0.45` | minimum confidence for a `match_medium` result | +| `conf_low` | `0.25` | minimum confidence for a `match_low` result | +| `max_words` | `50` | utterances longer than this are skipped unmatched | + +Keep the thresholds ordered `conf_low <= conf_med <= conf_high`; an inverted +order makes a tier unreachable. + +`max_words` is a guard: very long utterances are rarely commands and are +expensive to expand into cliques, so they are dropped before matching. + +## Tuning + +- **Too many false matches** (the assistant acts on off-hand remarks) — raise + `conf_high`, or give the over-eager intents more `require`d slots so they + demand a fuller command. See [Concepts](concepts.md). +- **Real commands missed** — check the utterance actually contains a registered + surface form for every required slot; confidence cannot rescue a missing + `require`. Lowering `conf_low` only helps if the intent scored *something*. +- **Confidence feels low on correct matches** — short keywords in long + sentences score low by design (little of the utterance is covered). Register + longer, more specific surface forms, or add `optionally` slots that cover + more words. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9abd94e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,60 @@ +# ovos-adapt-pipeline-plugin documentation + +`ovos-adapt-pipeline-plugin` is an OVOS intent pipeline plugin. It matches a +spoken utterance to a registered intent using **keyword and rule matching** — +no training step, no model files, no GPU. It bundles a maintained fork of the +original MycroftAI [adapt](https://github.com/MycroftAI/adapt) parser. + +This documentation goes from zero to hero. If you have never written an intent, +start at [Concepts](concepts.md). If you just want it running, jump to the +[Quickstart](quickstart.md). + +## Reading order + +**New to intent parsing** — read in order: + +1. [Concepts](concepts.md) — what an entity, an intent, and a confidence score are +2. [Quickstart](quickstart.md) — install, enable, and match your first utterance +3. [Writing intents](writing-intents.md) — register vocabulary and build intents +4. [Configuration](configuration.md) — confidence tiers and every config key + +**Building on or extending the plugin:** + +5. [Bus protocol](bus-protocol.md) — the messagebus API skills use to register +6. [Internals](internals.md) — the tagger, clique expansion, the confidence math + +## At a glance + +```text +utterance + -> tokenize into words + -> tag every entity found in the vocabulary Trie + -> expand the tags into non-overlapping entity sets ("cliques") + -> validate each intent's required / optional slots against a clique + -> confidence = matched-slot weight / total tags x clique coverage + -> the highest-confidence intent wins +``` + +## Where Adapt fits + +OVOS runs several intent matchers in a pipeline. Adapt is the **keyword/rule** +matcher: + +- **Adapt** — you list the words that trigger each intent. Deterministic, + registers instantly, no training. Best for command-style intents + (*"turn off the kitchen lights"*). +- **Padatious / Padacioso** — train a small model on example sentences. + Generalises to phrasings you did not write, at the cost of a training step. +- **Common Query / fallback** — catch-all stages for everything else. + +A skill can register intents with whichever matcher suits each intent. Adapt is +the right choice when the trigger words are known and finite. + +## Requirements + +- Python 3.10+ +- `ovos-plugin-manager`, `ovos-bus-client`, `ovos-config`, `ovos-utils`, + `ovos-workshop` + +The adapt parser itself (`ovos_adapt.engine`, `ovos_adapt.intent`) has no +runtime dependency beyond the standard library and can be used standalone. diff --git a/docs/internals.md b/docs/internals.md new file mode 100644 index 0000000..3eda308 --- /dev/null +++ b/docs/internals.md @@ -0,0 +1,126 @@ +# Internals + +This page traces what happens inside `determine_intent`, derives the confidence +formula from the source, and covers the domain engine and context. It is for +developers extending the plugin or debugging surprising scores. + +## The matching pipeline + +`IntentDeterminationEngine.determine_intent(utterance, num_results=1)` runs four +stages: + +```text +utterance + 1. tag EntityTagger -> every entity occurrence in the utterance + 2. expand BronKerboschExpander -> non-overlapping tag sets (cliques) + 3. validate Intent.validate_with_tags -> best intent per clique + 4. rank -> highest-confidence intent overall +``` + +### 1. Tagging + +The engine keeps every registered surface form in a **Trie** (`ovos_adapt`'s +`trie.py`). The `EntityTagger` walks the tokenised utterance against the Trie +and against any registered regex entities, emitting a tag for every match — +position, matched text, entity type, and a per-entity confidence. One word can +produce several tags if it was registered under several types. + +### 2. Clique expansion + +Tags can overlap in the utterance, and a valid answer cannot use two +overlapping tags at once. The `BronKerboschExpander` treats "does not overlap" +as a graph edge and finds **maximal cliques** — each clique is one complete, +self-consistent set of non-overlapping tags. Cliques are scored and yielded +best-first; `num_results` (`N`) caps how many are produced. + +The clique score (`score_clique` in `parser.py`) is: + +```text +clique_score = Σ entity_confidence × len(match) / (len(utterance) + 1) +``` + +summed over the clique's tags — i.e. how much of the utterance the clique +covers, weighted by each tag's confidence. + +### 3. Validation + +For each clique, `__best_intent` runs every registered intent's +`validate_with_tags(tags, clique_confidence)` and keeps the highest scorer. + +### 4. Ranking + +`determine_intent` yields one best-intent per clique. Callers take the global +maximum by `confidence`. + +## The confidence formula + +`Intent.validate_with_tags` (in `ovos_workshop.intents`) computes: + +```text +intent_confidence = Σ tag_confidence over the intent's used slots + (required + one_of + optional tags it matched) + +total_confidence = intent_confidence / len(tags) × clique_confidence +``` + +where `len(tags)` is **every** tag in the clique — including tags this intent +did not use. Three forces, then: + +- **`intent_confidence`** rises with each required/optional slot the intent + fills. More matched slots → higher score. +- **`len(tags)`** is a divisor over *all* clique tags. Tags belonging to other + intents still divide the score — unrelated keywords in the utterance + **dilute** every intent equally. +- **`clique_confidence`** is the coverage term — longer matched keywords, and + more of the utterance explained, score higher. + +A missing `require`d slot, an unmet `one_of`, or a hit `exclude` short-circuits +to `confidence = 0.0`. + +### Worked example + +Utterance *"turn off the kitchen lights"*, intent `lights:off` requiring +`OffKeyword` + `LightKeyword`, optional `RoomKeyword`. The best clique tags +`turn off` (Off), `kitchen` (Room), `lights` (Light) — 3 tags, all used by the +intent. `intent_confidence` sums all three; `len(tags)` is 3; `clique_confidence` +reflects that those three tags cover most of the sentence. The score is high. + +Add an unrelated registered word — say `play` — and a fourth tag appears. +`lights:off` still uses 3 tags but `len(tags)` is now 4, so its score drops even +though the command is unchanged. This dilution is the main reason scores differ +between engine topologies (see below). + +## DomainIntentDeterminationEngine + +`DomainIntentDeterminationEngine` groups intents into **domains**, each backed +by its own `IntentDeterminationEngine` — its own Trie, tagger, and parser set. +Registration takes a `domain=` argument; `determine_intent` scores every domain +and the caller takes the global argmax. + +Because each domain tags against only its own vocabulary, a domain's cliques +carry fewer foreign tags, so the `len(tags)` dilution above is smaller than in a +single flat engine. On a clean single-intent utterance this changes the score +but not the winner; on utterances carrying several intents' keywords it can +change which intent wins. + +## Context + +A returned match feeds its matched entities into the `Session` context. On the +next utterance the tagger seeds the Trie with those context entities (weighted +by recency), so a `require`d slot can be satisfied from context rather than from +the utterance's own words. This is how follow-up commands resolve. Context is +per-session and decays as new entities arrive. + +## Debugging tips + +- **An intent scores 0** — a required slot has no tag. Confirm the exact + surface form is registered (matching is exact) and that multi-word keywords + appear contiguously. +- **The wrong intent wins** — inspect `__tags__` on the result. Usually a + competing intent covered more of the utterance, or a stray keyword diluted + the right one. Add `require`d slots or an `exclude`. +- **Confidence lower than expected** — short keyword, long utterance: little is + covered. Register longer surface forms or add `optionally` slots. +- **Run the engine standalone** — drop the messagebus and call + `determine_intent` directly (see [Quickstart](quickstart.md)); print every + yielded result, not just the best, to see the full ranking. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..d3f9954 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,87 @@ +# Quickstart + +## Install + +```bash +pip install ovos-adapt-pipeline-plugin +``` + +This pulls in the OVOS pipeline plugin and the bundled adapt parser. + +## Match an utterance with the engine directly + +The adapt parser works standalone — no OVOS, no messagebus. This is the fastest +way to see it work and the best way to prototype intents. + +```python +from ovos_adapt.intent import IntentBuilder +from ovos_adapt.engine import IntentDeterminationEngine + +engine = IntentDeterminationEngine() + +# 1. register vocabulary: surface forms grouped by entity type +for word in ["weather", "forecast"]: + engine.register_entity(word, "WeatherKeyword") +for city in ["Seattle", "San Francisco", "Tokyo"]: + engine.register_entity(city, "Location") +for kind in ["snow", "rain", "wind", "sun"]: + engine.register_entity(kind, "WeatherType") + +# 2. build an intent over those entity types +weather = IntentBuilder("WeatherIntent") \ + .require("WeatherKeyword") \ + .require("Location") \ + .optionally("WeatherType") \ + .build() +engine.register_intent_parser(weather) + +# 3. match +for intent in engine.determine_intent("what is the rain forecast in Tokyo"): + if intent.get("confidence", 0) > 0: + print(intent) +``` + +Output (abridged): + +```python +{'intent_type': 'WeatherIntent', + 'WeatherKeyword': 'forecast', + 'Location': 'Tokyo', + 'WeatherType': 'rain', + 'confidence': 0.72} +``` + +`determine_intent` yields one result per clique, best first. Filter on +`confidence > 0` — a zero score means a required slot was missing. + +More runnable examples ship in the [`examples/`](../examples) folder. + +## Enable the pipeline in OVOS + +In a full OVOS install the plugin runs as a pipeline stage. Add its entry-point +id to the pipeline list in `mycroft.conf`: + +```json +{ + "intents": { + "pipeline": [ + "ovos-adapt-pipeline-plugin-high", + "ovos-padatious-pipeline-plugin-high", + "ovos-adapt-pipeline-plugin-medium", + "ovos-adapt-pipeline-plugin-low" + ] + } +} +``` + +The `-high` / `-medium` / `-low` suffixes select the confidence tier for that +slot in the pipeline (see [Configuration](configuration.md)). Skills then +register their vocabulary and intents over the messagebus; the plugin matches +incoming utterances automatically. You do not call the engine yourself — see +[Bus protocol](bus-protocol.md) for what happens under the hood. + +## Next + +- [Writing intents](writing-intents.md) — `require` / `optionally` / `one_of` / + `exclude`, regex entities, and full examples. +- [Configuration](configuration.md) — tune the confidence thresholds. diff --git a/docs/writing-intents.md b/docs/writing-intents.md new file mode 100644 index 0000000..927e82a --- /dev/null +++ b/docs/writing-intents.md @@ -0,0 +1,148 @@ +# Writing intents + +This page is the practical reference for defining vocabulary and intents. + +## Registering vocabulary + +Vocabulary is registered on the engine as `(surface_form, entity_type)` pairs. +Register every form a user might say: + +```python +engine.register_entity("lights", "LightKeyword") +engine.register_entity("light", "LightKeyword") +engine.register_entity("lamp", "LightKeyword") +``` + +All three are type `LightKeyword`; an intent that requires `LightKeyword` +matches if any one of them appears. Matching is exact and case-insensitive — +register plurals, contractions, and synonyms explicitly. + +### Aliases + +`alias_of` records that one surface form should be reported as another — useful +when several spellings should resolve to one canonical value: + +```python +engine.register_entity("NYC", "City", alias_of="New York") +``` + +A match on `NYC` reports the value `New York`. + +## Building intents + +`IntentBuilder` is a fluent builder. Chain rules, then `build()`: + +```python +from ovos_adapt.intent import IntentBuilder + +intent = IntentBuilder("kitchen.lights:turn_off") \ + .require("OffKeyword") \ + .require("LightKeyword") \ + .optionally("RoomKeyword") \ + .build() +engine.register_intent_parser(intent) +``` + +### `require(entity_type)` + +The intent only fires when a tag of this type is present. Missing any required +type scores the intent **zero**. Use `require` for the words that define the +command. + +### `optionally(entity_type)` + +Used when present, ignored when absent. An optional tag that *is* found raises +the confidence (it explains more of the utterance) but never blocks a match. +Use it for refinements — a room name, a media genre. + +### `one_of([entity_type, ...])` + +At least one type from the list must be present. Use it when a command can be +triggered several equivalent ways: + +```python +IntentBuilder("media:stop") \ + .one_of(["StopKeyword", "PauseKeyword", "HaltKeyword"]) \ + .require("MediaKeyword") \ + .build() +``` + +### `exclude(entity_type)` + +The intent is rejected outright if a tag of this type is present. Use it to +keep two similar intents apart: + +```python +# "play music" but NOT "stop music" +IntentBuilder("media:play") \ + .require("PlayKeyword") \ + .require("MediaKeyword") \ + .exclude("StopKeyword") \ + .build() +``` + +## Regex entities + +For values you cannot enumerate — numbers, free text — register a regular +expression with a named group. The group name becomes the entity type. + +```python +engine.register_regex_entity(r"for (?P\d+) minutes") +``` + +A match on *"set a timer for 10 minutes"* produces a `Duration` tag with value +`10`. Use regex entities for slots; keep `require`/`optionally` keywords for the +words that identify the command. + +## A complete example + +```python +from ovos_adapt.intent import IntentBuilder +from ovos_adapt.engine import IntentDeterminationEngine + +engine = IntentDeterminationEngine() + +for w in ["turn on", "switch on"]: + engine.register_entity(w, "OnKeyword") +for w in ["turn off", "switch off"]: + engine.register_entity(w, "OffKeyword") +for w in ["light", "lights", "lamp"]: + engine.register_entity(w, "LightKeyword") +for w in ["kitchen", "bedroom", "hallway"]: + engine.register_entity(w, "RoomKeyword") + +engine.register_intent_parser( + IntentBuilder("lights:on") + .require("OnKeyword").require("LightKeyword") + .optionally("RoomKeyword").build()) + +engine.register_intent_parser( + IntentBuilder("lights:off") + .require("OffKeyword").require("LightKeyword") + .optionally("RoomKeyword").build()) + +for intent in engine.determine_intent("switch on the kitchen lights"): + if intent.get("confidence", 0) > 0: + print(intent["intent_type"], intent.get("RoomKeyword")) + # -> lights:on kitchen +``` + +## Tips + +- **Name intents `skill_id:intent_name`.** OVOS routes a match back to the + skill by the `skill_id` prefix; the colon convention is expected. +- **Multi-word keywords must be contiguous.** `"turn off"` is registered as one + surface form and only tags when those words appear together. `"turn the + lights off"` will *not* match the keyword `"turn off"`. +- **More `require`d slots = a sharper, higher-confidence intent.** A + single-keyword intent fires on that lone word in any sentence; a two-slot + intent demands a real command shape. +- **Disambiguate overlapping intents** with `exclude`, not by hoping confidence + sorts them out. + +## In an OVOS skill + +Skills built with `ovos-workshop` do not call the engine directly — they +register vocabulary and intents over the messagebus, and this plugin consumes +those messages. The `IntentBuilder` API above is exactly what the skill side +uses. See [Bus protocol](bus-protocol.md) for the message flow. From 15343b754d03eb88bf29b57648a5a415698aec61 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 22 May 2026 12:42:27 +0000 Subject: [PATCH 13/38] Increment Version to 1.2.0a2 --- ovos_adapt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 4b250d3..31f1fd3 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -2,7 +2,7 @@ VERSION_MAJOR = 1 VERSION_MINOR = 2 VERSION_BUILD = 0 -VERSION_ALPHA = 1 +VERSION_ALPHA = 2 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") From 950b7b60ceee36850e108e42b7c9576b97585a4b Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 22 May 2026 12:42:48 +0000 Subject: [PATCH 14/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d84c96..c0c139b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.2.0a2](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.2.0a2) (2026-05-22) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.2.0a1...1.2.0a2) + +**Merged pull requests:** + +- docs: zero-to-hero documentation guide [\#39](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/39) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.2.0a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.2.0a1) (2026-05-21) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.1.0a1...1.2.0a1) From 62c2d52e815180927c7542c8c5d454a776d04164 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 22 May 2026 17:29:36 +0100 Subject: [PATCH 15/38] feat: HierarchicalIntentDeterminationEngine (#37) * feat: expose DomainIntentDeterminationEngine as opm.pipeline entry point Add `DomainAdaptPipeline`, a sibling of `AdaptPipeline` that wraps adapt's `DomainIntentDeterminationEngine`. Each `skill_id` gets its own sub-engine ("domain"); at match time every domain is scored in parallel and a global argmax over the union of candidates wins. No top-level router is involved. Intent / vocab / regex registrations are routed by the skill_id prefix of the intent label (`skill_id:intent_name`). Configurable via `intents.ovos_adapt_domain_pipeline`. * test: add flat-vs-domain engine benchmark Port the keyword-intent benchmark dataset and add a comparison harness that runs the same vocabulary and intents through IntentDeterminationEngine and DomainIntentDeterminationEngine. Results are documented in docs/benchmark.md. Co-Authored-By: Claude Opus 4.7 (1M context) * style: drop extraneous f-string prefix in benchmark * test: add entity-overlap cases and flat-vs-domain head-to-head Extend the benchmark dataset with utterances built around words registered under multiple entity types across domains, the only inputs where per-domain trie isolation can change tagging. Add a head-to-head report listing the cases where the two engines disagree. Co-Authored-By: Claude Opus 4.7 (1M context) * test: add two-stage hierarchical engine to the benchmark Add a third runner that classifies the domain first, then resolves the intent within only that domain, plus a stage-1 routing-accuracy metric. Two-stage routing scores 74.5% against 79.3% for flat and parallel-domain: hard routing misroutes 15% of utterances unrecoverably. Co-Authored-By: Claude Opus 4.7 (1M context) * test: redesign benchmark dataset with two-slot intents Rework the dataset around two-slot intents (shared ACTION keyword plus domain-distinctive OBJECT keyword) so single stray keywords no longer over-trigger and domains are lexically separable. Replace the stage-1 router with a keyword-coverage classifier, which routes 98% of cases correctly and lets the hierarchical engine suppress cross-domain false positives. Co-Authored-By: Claude Opus 4.7 (1M context) * test: add discriminating cases that separate the three engines Add overlapping vocabulary (shared temperature keyword, optional room slots) and three hand-crafted case sections, each built to give one topology a clear edge: routing-hard commands where flat and domain beat hierarchical, two-clause utterances where flat and domain diverge, and bare-keyword non-commands where hierarchical's gate suppresses false positives. The three engines now score 87.2 / 88.2 / 90.3 percent. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: frame the engine comparison as a tuned reference, not a benchmark State plainly that the dataset is hand-tuned to surface prediction differences between the topologies, that the accuracy ordering is an artifact of the discriminating-section mix, and how the dataset can be composed to favour any engine. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: add HierarchicalIntentDeterminationEngine and pipeline Make the two-stage hierarchical engine a first-class member of the package, consistent with the flat and domain engines: a HierarchicalIntentDeterminationEngine subclass of DomainIntentDeterminationEngine, a HierarchicalAdaptPipeline subclass of DomainAdaptPipeline, and a matching opm.pipeline entry point. The benchmark now drives all three through the same engine API instead of ad-hoc classifier glue. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address CodeRabbit review on PR #37 - Serialise domain/parser reads and mutations under self.lock in the domain pipeline (candidate gathering, detach_intent, shutdown). - Route regex vocab registrations by entity_type, not regex_str, so _resolve_entity_domain receives the prefix key it expects. - Validate the intent->domain map is one-to-one before building it. - Guard the stage-1 routing percentage against an empty match set. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: cover the domain and hierarchical pipelines Add docs/pipelines.md describing the three pipeline variants and when to use each, and wire it into the index, configuration, internals, and README alongside the engine-comparison reference. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 14 +- benchmark/__init__.py | 0 benchmark/compare.py | 284 +++++++++++++++++++++++ benchmark/dataset.py | 435 +++++++++++++++++++++++++++++++++++ docs/benchmark.md | 147 ++++++++++++ docs/configuration.md | 11 +- docs/index.md | 6 +- docs/internals.md | 34 ++- docs/pipelines.md | 63 +++++ ovos_adapt/engine.py | 77 +++++++ ovos_adapt/opm.py | 253 +++++++++++++++++++- pyproject.toml | 2 + test/test_domain_pipeline.py | 94 ++++++++ 13 files changed, 1401 insertions(+), 19 deletions(-) create mode 100644 benchmark/__init__.py create mode 100644 benchmark/compare.py create mode 100644 benchmark/dataset.py create mode 100644 docs/benchmark.md create mode 100644 docs/pipelines.md create mode 100644 test/test_domain_pipeline.py diff --git a/README.md b/README.md index 0159dfc..6de7218 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,17 @@ Adapt Intent Parser ================== The Adapt Intent Parser is a flexible and extensible intent definition and determination framework. It is intended to parse natural language text into a structured intent that can then be invoked programatically. -This repository contains a OVOS pipeline plugin and bundles a fork of the original [adapt-parser](https://github.com/MycroftAI/adapt) from the defunct MycroftAI +This repository contains a OVOS pipeline plugin and bundles a fork of the original [adapt-parser](https://github.com/MycroftAI/adapt) from the defunct MycroftAI. + +Pipeline Plugins +---------------- +Three OPM pipeline entry points are exposed: + +- `ovos-adapt-pipeline-plugin` (`AdaptPipeline`) — the flat pipeline, wrapping a single `IntentDeterminationEngine`. All skills share one trie. +- `ovos-adapt-domain-pipeline-plugin` (`DomainAdaptPipeline`) — a per-skill pipeline wrapping `DomainIntentDeterminationEngine`. Each `skill_id` gets its own sub-engine ("domain"); at match time every domain is scored in parallel and a global argmax wins. Configurable under `intents.ovos_adapt_domain_pipeline`. +- `ovos-adapt-hierarchical-pipeline-plugin` (`HierarchicalAdaptPipeline`) — the per-skill domain model with two-stage routing: a stage-1 classifier picks one domain, then only that domain's sub-engine is scored. Configurable under `intents.ovos_adapt_hierarchical_pipeline`. + +See [Pipeline variants](docs/pipelines.md) for when to use each. Documentation ============= @@ -14,8 +24,10 @@ A zero-to-hero guide lives in [`docs/`](docs/index.md): - [Quickstart](docs/quickstart.md) — install, enable, match your first utterance - [Writing intents](docs/writing-intents.md) — the `IntentBuilder` API with examples - [Configuration](docs/configuration.md) — confidence tiers and every config key +- [Pipeline variants](docs/pipelines.md) — the flat, domain, and hierarchical plugins - [Bus protocol](docs/bus-protocol.md) — the messagebus API skills register over - [Internals](docs/internals.md) — tagging, clique expansion, the confidence math +- [Engine comparison reference](docs/benchmark.md) — how the variants diverge Examples ======== diff --git a/benchmark/__init__.py b/benchmark/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmark/compare.py b/benchmark/compare.py new file mode 100644 index 0000000..e6ca618 --- /dev/null +++ b/benchmark/compare.py @@ -0,0 +1,284 @@ +""" +Engine-comparison harness: flat vs domain vs hierarchical Adapt. + +All three runners use the same keyword vocabulary and intent definitions. +The only difference is engine topology: + +- **flat** — one :class:`IntentDeterminationEngine`; every intent + parser and every entity share a single Trie and tagger. +- **domain** — one :class:`DomainIntentDeterminationEngine`; intents are + grouped into domains, each domain owning an isolated sub-engine. Every + domain is scored and the global argmax wins. +- **hierarchical** — one :class:`HierarchicalIntentDeterminationEngine`; a + stage-1 classifier picks one domain, then only that domain's sub-engine is + scored. + +This is a hand-tuned reference corpus, not a representative benchmark — see +docs/benchmark.md. + +Usage +----- + python benchmark/compare.py +""" +import statistics +import sys +import time +from collections import defaultdict +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from benchmark.dataset import ( # noqa: E402 + VOCAB, INTENTS, DOMAINS, TEST_CASES, NO_MATCH_UTTERANCES, +) +from ovos_adapt.engine import ( # noqa: E402 + IntentDeterminationEngine, DomainIntentDeterminationEngine, + HierarchicalIntentDeterminationEngine, +) +from ovos_adapt.intent import IntentBuilder # noqa: E402 + + +# ── shared helpers ───────────────────────────────────────────────────────── + +def all_cases(): + return list(TEST_CASES) + [(u, None) for u in NO_MATCH_UTTERANCES] + + +def _build_parser(intent_name): + slots = INTENTS[intent_name] + builder = IntentBuilder(intent_name) + for slot in slots["required"]: + builder.require(slot) + for slot in slots["optional"]: + builder.optionally(slot) + return builder.build() + + +def _entity_types(intent_names): + etypes = set() + for name in intent_names: + etypes.update(INTENTS[name]["required"]) + etypes.update(INTENTS[name]["optional"]) + return etypes + + +#: intent_name -> its domain +INTENT_DOMAIN = {} +for _domain, _names in DOMAINS.items(): + for _intent in _names: + if _intent in INTENT_DOMAIN: + raise ValueError( + f"Intent '{_intent}' assigned to multiple domains: " + f"{INTENT_DOMAIN[_intent]} and {_domain}") + INTENT_DOMAIN[_intent] = _domain + + +def _best_name(intents): + """Global argmax intent label over a list of Adapt parse dicts.""" + if not intents: + return None, 0.0 + best = max(intents, key=lambda x: x.get("confidence", 0.0)) + conf = best.get("confidence", 0.0) + return (best.get("intent_type") if conf > 0 else None), conf + + +def compute_metrics(results, cases): + total = len(cases) + match_n = sum(1 for _, e in cases if e is not None) + nomatch_n = total - match_n + tp = fp = fn = tn = 0 + per_tp = defaultdict(int) + per_fn = defaultdict(int) + per_fp = defaultdict(int) + wrong = [] + for (predicted, conf), (utt, expected) in zip(results, cases): + if expected is not None: + if predicted == expected: + tp += 1 + per_tp[expected] += 1 + else: + fn += 1 + per_fn[expected] += 1 + if predicted is not None: + fp += 1 + per_fp[predicted] += 1 + wrong.append((utt, expected, predicted, conf)) + else: + if predicted is not None: + fp += 1 + per_fp[predicted] += 1 + wrong.append((utt, expected, predicted, conf)) + else: + tn += 1 + prec = tp / (tp + fp) if (tp + fp) else 0.0 + rec = tp / match_n if match_n else 0.0 + f1 = 2 * prec * rec / (prec + rec) if (prec + rec) else 0.0 + return dict( + accuracy=(tp + tn) / total, precision=prec, recall=rec, f1=f1, + tp=tp, fp=fp, fn=fn, tn=tn, + match_n=match_n, nomatch_n=nomatch_n, + per_tp=per_tp, per_fn=per_fn, per_fp=per_fp, wrong=wrong, + ) + + +def print_report(label, m, latencies): + s = sorted(latencies) + total = m["match_n"] + m["nomatch_n"] + print(f"\n{'=' * 66}") + print(f" {label}") + print(f"{'=' * 66}") + print(f" Accuracy : {m['accuracy']:.1%} ({int(round(m['accuracy'] * total))}/{total})") + print(f" Precision : {m['precision']:.1%}") + print(f" Recall : {m['recall']:.1%}") + print(f" F1 : {m['f1']:.3f}") + print(f" TN : {m['tn']} / {m['nomatch_n']} ({m['tn'] / m['nomatch_n']:.0%} of no-match)") + print(f" FP : {m['fp']} / {m['nomatch_n']} ({m['fp'] / m['nomatch_n']:.0%} of no-match)") + print(f" FN : {m['fn']} / {m['match_n']} ({m['fn'] / m['match_n']:.0%} of match)") + print(f" Latency : median={statistics.median(latencies):.2f}ms " + f"p95={s[int(len(s) * .95)]:.2f}ms max={s[-1]:.2f}ms") + if m["wrong"]: + print(f"\n Mismatches ({len(m['wrong'])}):") + for utt, exp, pred, conf in m["wrong"]: + print(f" [{exp or '—'} → {pred or '—'}] ({conf:.2f}) \"{utt}\"") + + +# ── engine runners ───────────────────────────────────────────────────────── + +def run_flat(cases): + engine = IntentDeterminationEngine() + for entity_type, values in VOCAB.items(): + for value in values: + engine.register_entity(value, entity_type) + for intent_name in INTENTS: + engine.register_intent_parser(_build_parser(intent_name)) + + results, latencies = [], [] + for utt, _ in cases: + t0 = time.perf_counter() + intents = list(engine.determine_intent(utt, 100)) + latencies.append((time.perf_counter() - t0) * 1000) + results.append(_best_name(intents)) + + m = compute_metrics(results, cases) + print_report("flat — IntentDeterminationEngine", m, latencies) + return m, statistics.median(latencies), results + + +def run_domain(cases): + engine = DomainIntentDeterminationEngine() + for domain, intent_names in DOMAINS.items(): + for etype in _entity_types(intent_names): + for value in VOCAB.get(etype, []): + engine.register_entity(value, etype, domain=domain) + for intent_name in intent_names: + engine.register_intent_parser(_build_parser(intent_name), + domain=domain) + + results, latencies = [], [] + for utt, _ in cases: + t0 = time.perf_counter() + intents = list(engine.determine_intent(utt, 100)) + latencies.append((time.perf_counter() - t0) * 1000) + results.append(_best_name(intents)) + + m = compute_metrics(results, cases) + print_report("domain — DomainIntentDeterminationEngine", m, latencies) + return m, statistics.median(latencies), results + + +# ── summary table ────────────────────────────────────────────────────────── + +def run_hierarchical(cases): + """Two-stage routing via HierarchicalIntentDeterminationEngine. + + Registers the same per-domain intents as the parallel domain engine. + ``determine_intent`` classifies the domain and resolves only within it; + a wrong stage-1 route cannot be recovered. + """ + engine = HierarchicalIntentDeterminationEngine() + for domain, intent_names in DOMAINS.items(): + for etype in _entity_types(intent_names): + for value in VOCAB.get(etype, []): + engine.register_entity(value, etype, domain=domain) + for intent_name in intent_names: + engine.register_intent_parser(_build_parser(intent_name), + domain=domain) + + results, latencies = [], [] + routed_ok = routed_total = 0 + for utt, expected in cases: + t0 = time.perf_counter() + intents = list(engine.determine_intent(utt, 100)) + latencies.append((time.perf_counter() - t0) * 1000) + results.append(_best_name(intents)) + if expected is not None: + routed_total += 1 + if engine.classify_domain(utt) == INTENT_DOMAIN.get(expected): + routed_ok += 1 + + m = compute_metrics(results, cases) + print_report("hierarchical — HierarchicalIntentDeterminationEngine", + m, latencies) + routed_pct = f"{routed_ok / routed_total:.0%}" if routed_total else "n/a" + print(f" Stage-1 routing : {routed_ok}/{routed_total} match cases " + f"routed to the correct domain ({routed_pct})") + return m, statistics.median(latencies), results + + +def head_to_head(cases, flat_results, domain_results): + """Report the cases where flat and domain predict a different intent.""" + diffs = [] + for (utt, expected), (fn, fc), (dn, dc) in zip(cases, flat_results, + domain_results): + if fn != dn: + diffs.append((utt, expected, fn, fc, dn, dc)) + print(f"\n\n{'=' * 66}") + print(" Head-to-head: flat vs domain") + print(f"{'=' * 66}") + print(f" Cases : {len(cases)}") + print(f" Same prediction : {len(cases) - len(diffs)}") + print(f" Different : {len(diffs)}") + if diffs: + print(f"\n {'utterance':<44} {'expected':<16} {'flat':<16} {'domain':<16}") + print(f" {'-' * 90}") + for utt, exp, fn, fc, dn, dc in diffs: + u = utt if len(utt) <= 42 else utt[:41] + "…" + print(f" {u:<44} {exp or '—':<16} " + f"{(fn or '—') + f' {fc:.2f}':<16} " + f"{(dn or '—') + f' {dc:.2f}':<16}") + return len(diffs) + + +def summary(rows): + print(f"\n\n{'─' * 84}") + print(f" {'Engine':<14} {'Acc':>6} {'Prec':>6} {'Recall':>7} {'F1':>6} " + f"{'TN/NM':>8} {'FP':>4} {'FN':>4} {'Median':>8}") + print(f"{'─' * 84}") + for label, m, median_lat in rows: + tn_frac = f"{m['tn']}/{m['nomatch_n']}" + print(f" {label:<14} {m['accuracy']:>5.1%} {m['precision']:>5.1%} " + f"{m['recall']:>6.1%} {m['f1']:>5.3f} {tn_frac:>8} " + f"{m['fp']:>4} {m['fn']:>4} {median_lat:>6.2f}ms") + print(f"{'─' * 84}") + print(" TN/NM = true negatives / total no-match cases (correctly returned nothing)") + + +# ── main ─────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + cases = all_cases() + match_n = sum(1 for _, e in cases if e is not None) + print(f"\nDataset : {len(cases)} cases ({match_n} match, {len(cases) - match_n} no-match)") + print(f"Intents : {len(INTENTS)} across {len(DOMAINS)} domains") + print(f"Vocab : {sum(len(v) for v in VOCAB.values())} keyword samples " + f"across {len(VOCAB)} entity types") + + rows = [] + m, lat, flat_results = run_flat(cases) + rows.append(("flat", m, lat)) + m, lat, domain_results = run_domain(cases) + rows.append(("domain", m, lat)) + m, lat, _ = run_hierarchical(cases) + rows.append(("hierarchical", m, lat)) + head_to_head(cases, flat_results, domain_results) + summary(rows) diff --git a/benchmark/dataset.py b/benchmark/dataset.py new file mode 100644 index 0000000..ef94c1e --- /dev/null +++ b/benchmark/dataset.py @@ -0,0 +1,435 @@ +""" +Engine-comparison reference corpus for the Adapt engine. + +This is NOT a benchmark. It is a small, hand-tuned dataset built to expose +behavioural differences between three engine topologies — the flat +``IntentDeterminationEngine``, the parallel ``DomainIntentDeterminationEngine``, +and a two-stage hierarchical router. It is not a representative sample of real +traffic; its accuracy numbers are an artifact of how the discriminating +sections are mixed and can be skewed to favour any topology. See +``docs/benchmark.md`` ("Reading the numbers"). + +Design +------ +Intents are mostly **two-slot** — an ACTION keyword plus an OBJECT keyword — +so a single stray keyword does not trigger an intent on its own. OBJECT +vocabularies are domain-distinctive (``thermostat`` only ever appears in +*climate*, ``playlist`` only in *media*), which lets a domain classifier +route reliably. ACTION vocabularies are deliberately shared across domains +(``turn up`` is both a volume and a heating action), so disambiguation +depends on the object — the case that separates the engine topologies. + +A handful of intents are genuinely single-trigger (``weather_query``, +``get_help``, ``navigate_to``) and stay one-slot. + +Structure +--------- +VOCAB entity_type -> [entity_value, ...] (ACTION_* and OBJ_* groups) +INTENTS intent_name -> {"required": [...], "optional": [...]} +DOMAINS domain_name -> [intent_name, ...] +TEST_CASES [(utterance, expected_intent), ...] +NO_MATCH_UTTERANCES [utterance, ...] (should match nothing) +""" + +# ── action vocabulary (shared across domains) ────────────────────────────── + +VOCAB = { + "ACTION_PLAY": ["play", "put on", "queue"], + "ACTION_STOP": ["stop", "pause", "halt"], + "ACTION_SKIP": ["next", "skip"], + "ACTION_RAISE": ["turn up", "crank up", "raise", "increase"], + "ACTION_LOWER": ["turn down", "lower", "decrease"], + "ACTION_ON": ["turn on", "switch on", "enable"], + "ACTION_OFF": ["turn off", "switch off", "disable"], + "ACTION_SET": ["set", "create", "make", "schedule", "start"], + "ACTION_CANCEL": ["cancel", "delete", "remove", "clear"], + "ACTION_ASK": ["what", "how", "tell me", "check", "give me"], + "ACTION_SEND": ["send", "write", "jot down", "leave", "draft"], + "ACTION_GO": ["navigate to", "take me to", "drive to", + "directions to", "get me to"], + + # ── object vocabulary (domain-distinctive) ───────────────────────────── + "OBJ_AUDIO": ["volume", "sound", "music", "song", "stereo"], + "OBJ_MUSIC": ["music", "song", "track", "playlist", "album", + "podcast", "tune"], + "OBJ_LIGHT": ["light", "lights", "lamp", "bulb", "lighting"], + "OBJ_HVAC": ["thermostat", "heating", "heater", "radiator", + "air conditioning", "temperature"], + "OBJ_ROOM": ["bedroom", "kitchen", "living room", "bathroom", + "hallway", "office", "garage"], + "OBJ_TIMER": ["timer", "countdown"], + "OBJ_ALARM": ["alarm"], + "OBJ_WEATHER": ["weather", "forecast", "rain", "umbrella", + "temperature", "snow"], + "OBJ_REMINDER": ["reminder", "remind me"], + "OBJ_PHONE": ["call", "phone", "dial", "ring"], + "OBJ_MESSAGE": ["message", "text", "email"], + "OBJ_NOTE": ["note", "memo"], + "OBJ_SHOPPING": ["shopping list", "groceries", "shopping"], + "OBJ_TIME": ["time", "clock"], + "OBJ_DATE": ["date", "today", "what day"], + "OBJ_SEARCH": ["search", "look up", "google"], + "OBJ_HELP": ["help", "commands"], + "OBJ_HALT": ["stop", "cancel", "abort", "never mind"], +} + +# ── intent definitions ───────────────────────────────────────────────────── + +INTENTS = { + # media + "play_music": {"required": ["ACTION_PLAY", "OBJ_MUSIC"], "optional": []}, + "pause_music": {"required": ["ACTION_STOP", "OBJ_MUSIC"], "optional": []}, + "next_track": {"required": ["ACTION_SKIP"], "optional": ["OBJ_MUSIC"]}, + "volume_up": {"required": ["ACTION_RAISE", "OBJ_AUDIO"], "optional": []}, + "volume_down": {"required": ["ACTION_LOWER", "OBJ_AUDIO"], "optional": []}, + # lights + "lights_on": {"required": ["ACTION_ON", "OBJ_LIGHT"], + "optional": ["OBJ_ROOM"]}, + "lights_off": {"required": ["ACTION_OFF", "OBJ_LIGHT"], + "optional": ["OBJ_ROOM"]}, + # climate + "heating_up": {"required": ["ACTION_RAISE", "OBJ_HVAC"], + "optional": ["OBJ_ROOM"]}, + "heating_down": {"required": ["ACTION_LOWER", "OBJ_HVAC"], + "optional": ["OBJ_ROOM"]}, + "check_hvac": {"required": ["ACTION_ASK", "OBJ_HVAC"], "optional": []}, + # timers & alarms + "set_timer": {"required": ["ACTION_SET", "OBJ_TIMER"], "optional": []}, + "cancel_timer": {"required": ["ACTION_CANCEL", "OBJ_TIMER"], "optional": []}, + "set_alarm": {"required": ["ACTION_SET", "OBJ_ALARM"], "optional": []}, + "cancel_alarm": {"required": ["ACTION_CANCEL", "OBJ_ALARM"], "optional": []}, + # weather + "weather_query": {"required": ["OBJ_WEATHER"], "optional": []}, + # reminders + "add_reminder": {"required": ["OBJ_REMINDER"], "optional": []}, + # communication + "call_contact": {"required": ["OBJ_PHONE"], "optional": []}, + "send_message": {"required": ["ACTION_SEND", "OBJ_MESSAGE"], "optional": []}, + "add_note": {"required": ["ACTION_SEND", "OBJ_NOTE"], "optional": []}, + # shopping + "add_shopping": {"required": ["OBJ_SHOPPING"], "optional": []}, + # information + "time_query": {"required": ["ACTION_ASK", "OBJ_TIME"], "optional": []}, + "date_query": {"required": ["ACTION_ASK", "OBJ_DATE"], "optional": []}, + "search_query": {"required": ["OBJ_SEARCH"], "optional": []}, + # navigation + "navigate_to": {"required": ["ACTION_GO"], "optional": []}, + # system + "get_help": {"required": ["OBJ_HELP"], "optional": []}, + "stop_all": {"required": ["OBJ_HALT"], "optional": []}, +} + +# ── domain grouping ──────────────────────────────────────────────────────── + +DOMAINS = { + "media": ["play_music", "pause_music", "next_track", + "volume_up", "volume_down"], + "lights": ["lights_on", "lights_off"], + "climate": ["heating_up", "heating_down", "check_hvac"], + "timers": ["set_timer", "cancel_timer", "set_alarm", "cancel_alarm"], + "weather": ["weather_query"], + "reminders": ["add_reminder"], + "communication": ["call_contact", "send_message", "add_note"], + "shopping": ["add_shopping"], + "information": ["time_query", "date_query", "search_query"], + "navigation": ["navigate_to"], + "system": ["get_help", "stop_all"], +} + +# ── labelled test utterances ─────────────────────────────────────────────── + +TEST_CASES = [ + # play_music — ACTION_PLAY + OBJ_MUSIC + ("play some music", "play_music"), + ("put on a song", "play_music"), + ("play my favourite playlist", "play_music"), + ("queue up the next album", "play_music"), + ("put on a podcast", "play_music"), + ("play that track again please", "play_music"), + ("can you play some music", "play_music"), + + # pause_music — ACTION_STOP + OBJ_MUSIC + ("pause the music", "pause_music"), + ("stop the song", "pause_music"), + ("halt the playlist", "pause_music"), + ("pause this track for a sec", "pause_music"), + ("can you stop the music please", "pause_music"), + + # next_track — ACTION_SKIP (+ OBJ_MUSIC) + ("next track", "next_track"), + ("skip this song", "next_track"), + ("skip", "next_track"), + ("next please", "next_track"), + ("skip to the next track", "next_track"), + + # volume_up — ACTION_RAISE + OBJ_AUDIO + ("turn up the volume", "volume_up"), + ("crank up the volume please", "volume_up"), + ("raise the volume a bit", "volume_up"), + ("increase the volume", "volume_up"), + ("turn up the music", "volume_up"), + + # volume_down — ACTION_LOWER + OBJ_AUDIO + ("turn down the volume", "volume_down"), + ("lower the volume", "volume_down"), + ("decrease the volume a little", "volume_down"), + ("turn down the music please", "volume_down"), + + # lights_on — ACTION_ON + OBJ_LIGHT + ("turn on the lights", "lights_on"), + ("switch on the lamp", "lights_on"), + ("turn on the light in here", "lights_on"), + ("enable the lighting", "lights_on"), + ("can you turn on the lights", "lights_on"), + + # lights_off — ACTION_OFF + OBJ_LIGHT + ("turn off the lights", "lights_off"), + ("switch off the lamp", "lights_off"), + ("turn off the light please", "lights_off"), + ("disable the lighting", "lights_off"), + + # heating_up — ACTION_RAISE + OBJ_HVAC + ("turn up the heating", "heating_up"), + ("crank up the heater", "heating_up"), + ("raise the thermostat", "heating_up"), + ("increase the heating a bit", "heating_up"), + + # heating_down — ACTION_LOWER + OBJ_HVAC + ("turn down the heating", "heating_down"), + ("lower the thermostat", "heating_down"), + ("decrease the heating", "heating_down"), + ("turn down the radiator please", "heating_down"), + + # check_hvac — ACTION_ASK + OBJ_HVAC + ("what's the thermostat at", "check_hvac"), + ("check the heating", "check_hvac"), + ("tell me the thermostat setting", "check_hvac"), + ("how's the heating doing", "check_hvac"), + + # set_timer — ACTION_SET + OBJ_TIMER + ("set a timer", "set_timer"), + ("create a timer for ten minutes", "set_timer"), + ("start a countdown", "set_timer"), + ("make a timer for five minutes", "set_timer"), + + # cancel_timer — ACTION_CANCEL + OBJ_TIMER + ("cancel the timer", "cancel_timer"), + ("delete the timer", "cancel_timer"), + ("clear the countdown", "cancel_timer"), + ("remove the timer please", "cancel_timer"), + + # set_alarm — ACTION_SET + OBJ_ALARM + ("set an alarm", "set_alarm"), + ("create an alarm for seven", "set_alarm"), + ("make an alarm for the morning", "set_alarm"), + ("schedule an alarm", "set_alarm"), + + # cancel_alarm — ACTION_CANCEL + OBJ_ALARM + ("cancel the alarm", "cancel_alarm"), + ("delete the alarm", "cancel_alarm"), + ("remove the alarm", "cancel_alarm"), + ("clear the alarm please", "cancel_alarm"), + + # weather_query — OBJ_WEATHER + ("what's the weather", "weather_query"), + ("is it going to rain", "weather_query"), + ("do i need an umbrella", "weather_query"), + ("what's the forecast", "weather_query"), + ("is there snow coming", "weather_query"), + ("is it going to snow", "weather_query"), + + # add_reminder — OBJ_REMINDER + ("set a reminder", "add_reminder"), + ("remind me to buy milk", "add_reminder"), + ("create a reminder for the meeting", "add_reminder"), + ("add a reminder", "add_reminder"), + + # call_contact — OBJ_PHONE + ("call mum", "call_contact"), + ("phone the bank", "call_contact"), + ("dial charlie", "call_contact"), + ("ring my brother", "call_contact"), + + # send_message — ACTION_SEND + OBJ_MESSAGE + ("send a message to bob", "send_message"), + ("write a text to alice", "send_message"), + ("draft an email", "send_message"), + ("send a text please", "send_message"), + + # add_note — ACTION_SEND + OBJ_NOTE + ("write a note", "add_note"), + ("jot down a note", "add_note"), + ("leave a note for sam", "add_note"), + ("draft a memo", "add_note"), + + # add_shopping — OBJ_SHOPPING + ("add milk to the shopping list", "add_shopping"), + ("i need to buy groceries", "add_shopping"), + ("add eggs to the shopping", "add_shopping"), + + # time_query — ACTION_ASK + OBJ_TIME + ("what is the time", "time_query"), + ("tell me the time", "time_query"), + ("check the time for me", "time_query"), + ("what's the time", "time_query"), + + # date_query — ACTION_ASK + OBJ_DATE + ("what's the date", "date_query"), + ("tell me the date", "date_query"), + ("what is today's date", "date_query"), + ("what's today", "date_query"), + + # search_query — OBJ_SEARCH + ("search for nearby restaurants", "search_query"), + ("look up the train times", "search_query"), + ("google italian recipes", "search_query"), + ("search for a plumber", "search_query"), + + # navigate_to — ACTION_GO + ("navigate to the airport", "navigate_to"), + ("take me to the station", "navigate_to"), + ("drive to the office", "navigate_to"), + ("directions to the hospital", "navigate_to"), + ("get me to work", "navigate_to"), + + # get_help — OBJ_HELP + ("help", "get_help"), + ("what commands do you have", "get_help"), + ("i need help", "get_help"), + + # stop_all — OBJ_HALT + ("stop", "stop_all"), + ("cancel", "stop_all"), + ("abort", "stop_all"), + ("never mind", "stop_all"), + + # ── cross-domain action collision ───────────────────────────────────── + # The ACTION keyword is shared across domains; the OBJECT decides the + # domain. A correct engine follows the object, not the action. + ("turn up the heating in here", "heating_up"), + ("turn up the volume on the stereo", "volume_up"), + ("crank up the radiator", "heating_up"), + ("crank up the music", "volume_up"), + ("switch on the kitchen lamp", "lights_on"), + ("pause the podcast", "pause_music"), + ("set a timer for the pasta", "set_timer"), + ("set an alarm for the gym", "set_alarm"), + + # ── domain-context cases ────────────────────────────────────────────── + # The utterance's dominant intent matches cleanly on coverage, but a + # keyword for a single-slot intent in another domain is also present + # as a distractor. The dominant intent should still win. + ("play the song on the radio", "play_music"), + ("put on a podcast about the weather", "play_music"), + ("turn off the lights then call mum", "lights_off"), + ("write a note about the shopping", "add_note"), + ("set a timer for the laundry", "set_timer"), + + # ── discriminating: flat & domain win, hierarchical loses ───────────── + # Real single-intent commands. The OBJECT keyword is unambiguous, but the + # utterance also carries a long room/topic word that pulls the stage-1 + # coverage classifier to the wrong domain; the misroute is unrecoverable. + # One-word commands give the classifier nothing to route on at all. + ("play music in the living room", "play_music"), + ("turn up the volume in the bedroom", "volume_up"), + ("call mum about the heating", "call_contact"), + ("set a timer in the living room", "set_timer"), + ("look up the temperature", "search_query"), + ("google the heating thermostat", "search_query"), + ("the temperature please", "weather_query"), + ("stop the timer", "stop_all"), + + # ── discriminating: flat and domain diverge ─────────────────────────── + # Two-clause utterances carrying two intents. Labelled by the leading + # clause. Flat scores every parser against one shared clique; domain + # scores each clause in its own isolated sub-engine. Because adapt + # confidence divides each intent's score by the total tag count of its + # clique, the two topologies break the tie between clauses differently. + ("turn off the lights and stop the music", "lights_off"), + ("turn up the heating and lower the volume", "heating_up"), + ("turn on the lights and pause the music", "lights_on"), + ("play some music and turn on the lights", "play_music"), + ("lower the heating and pause the music", "heating_down"), + ("play a podcast and turn down the heating", "play_music"), +] + +# ── no-match utterances ──────────────────────────────────────────────────── +# Plausible but not commands. Many contain a keyword used outside a command +# context to stress the false-positive rate. + +NO_MATCH_UTTERANCES = [ + # conversational / off-topic + "um yeah so anyway", + "right okay then", + "hmm not sure about that", + "oh that's interesting", + "fair enough i suppose", + "the dog ate my homework", + "did you see the match last night", + "my knee has been giving me trouble", + "what a lovely afternoon", + "i really fancy a cup of tea", + "the cat has been acting strange", + "that film was brilliant", + "i can't believe how fast the year went", + + # single object keyword, no action — must not fire a two-slot intent + "the music at the restaurant was lovely", + "the lights looked beautiful at the concert", + "the heating bill was enormous this month", + "the alarm woke the whole street", + "the timer on the oven is broken", + "she left her phone on the table", + "the weather has been miserable lately", + "my shopping bag split open", + "the thermostat is on the hallway wall", + "the radiator needs bleeding again", + "i love a good podcast on a long drive", + + # action keyword, no object — must not fire a two-slot intent + "could you turn it up a bit", + "go ahead and switch it on", + "i need you to turn that down", + "just put it on for me", + "can you cancel it", + + # overlapping keyword used non-literally + "they cancel each other out", + "let's call it a day", + "he was dead set against the idea", + "give the new hire a warm welcome", + "kill the engine before you park", + "stop right there", + "i had to turn down the job offer", + "the music just would not stop", + + # rhetorical / reported speech + "who even sets an alarm on a sunday", + "she asked him to call her back", + "they said the music was too loud", + "what would you do if the lights went out", + "imagine if you could just skip the boring bits", + + # nonsense + "blarg wump fizz", + "one fish two fish red fish", + "lorem ipsum dolor sit amet", + "the mitochondria is the powerhouse of the cell", + + # ── discriminating: hierarchical wins, flat & domain lose ───────────── + # Not commands, but each contains a bare keyword for a single-slot intent + # (stop_all). Flat and domain fire stop_all on the lone word. The stage-1 + # classifier routes these to a two-slot domain (media or timers) whose + # intents need a second keyword that is absent, so nothing fires and no + # false positive is emitted. + "they cancel each other out", + "they never stop arguing", + "the bus stop was crowded", + "cancel culture is everywhere", + "we should cancel the trip", + "i had to cancel my plans", + "stop right there", + "they would not stop talking", + "the train made a quick stop", + "the cancel button was greyed out", +] diff --git a/docs/benchmark.md b/docs/benchmark.md new file mode 100644 index 0000000..afb30d7 --- /dev/null +++ b/docs/benchmark.md @@ -0,0 +1,147 @@ +# Engine comparison reference + +> **This is not a benchmark.** `benchmark/dataset.py` is a small, hand-tuned +> reference corpus built to *expose behavioural differences* between the three +> Adapt engine topologies. It is not a representative sample of real traffic, +> and the headline accuracy numbers below are an artifact of how the dataset is +> composed — see [Reading the numbers](#reading-the-numbers). Use it to +> understand *how* the topologies diverge and *why*, not to rank them. + +`benchmark/compare.py` runs three Adapt engine topologies on one shared +keyword dataset: + +- **flat** — a single `IntentDeterminationEngine`. Every intent parser and + every entity share one `Trie` and one entity tagger. +- **domain** — a `DomainIntentDeterminationEngine`. Intents are grouped into + domains; each domain owns an isolated sub-engine with its own `Trie` and + tagger. Every domain is scored and the global argmax wins. +- **hierarchical** — a `HierarchicalIntentDeterminationEngine` (a subclass of + `DomainIntentDeterminationEngine` with the same registration API). Its + `determine_intent` runs a stage-1 keyword-coverage classifier to pick one + domain, then evaluates only that domain's sub-engine. A wrong stage-1 route + cannot be recovered. + +## How the topologies can differ + +Adapt scores a parse with +`confidence = intent_confidence / len(clique_tags) × clique_confidence`, +where `clique_tags` is *every* entity tagged in the matched clique — including +entities from unrelated intents. So an intent's confidence is **diluted by the +number of foreign tags** sharing its clique. + +- **flat** tags an utterance against every domain's vocabulary at once, so + cliques carry the most foreign tags and dilute the most. +- **domain** tags each utterance against one domain's vocabulary at a time, so + cliques are smaller and the in-domain intent is diluted less. +- **hierarchical** additionally discards every domain but one before scoring. + +On a clean single-intent utterance all three pick the same winner — there is +no competitor for dilution to reorder. They diverge only on utterances that +carry more than one intent's keywords, or that the stage-1 router misroutes. + +## Dataset + +`benchmark/dataset.py` defines the vocabulary, intents, domain grouping, and +labelled utterances: + +```text +Cases : 195 (139 match, 56 no-match) +Intents : 26 across 11 domains +``` + +Intents are mostly **two-slot** — a shared ACTION keyword plus a +domain-distinctive OBJECT keyword. Beyond the everyday cases, the dataset +carries three hand-crafted discriminating sections, each *deliberately +constructed* to give one topology an edge: + +- **flat & domain win, hierarchical loses** — real commands carrying a long + room/topic word (or a bare one-word command) that pulls the stage-1 + classifier to the wrong domain. The misroute is unrecoverable. +- **flat and domain diverge** — two-clause utterances carrying two intents, + labelled by the leading clause. Flat scores both clauses in one shared + clique; domain scores each clause in its own sub-engine. The dilution term + breaks the clause tie differently. +- **hierarchical wins, flat & domain lose** — utterances that are not commands + but contain a bare keyword for a single-slot intent. Flat and domain fire on + the lone word; the classifier routes them to a two-slot domain where the + missing second keyword means nothing fires. + +## Results + +Single run, all engines on the same machine and dataset: + +| Engine | Accuracy | Precision | Recall | F1 | TN/NM | FP | FN | Median lat | +|---|---|---|---|---|---|---|---|---| +| flat | 87.2% | 84.3% | 96.4% | 0.899 | 36/56 | 25 | 5 | 0.21 ms | +| domain | 88.2% | 85.5% | 97.8% | 0.913 | 36/56 | 23 | 3 | 0.82 ms | +| hierarchical | 90.3% | 93.4% | 91.4% | 0.924 | 49/56 | 9 | 12 | 0.32 ms | + +Flat vs domain head-to-head: **6 / 195 different** — all six the two-clause +utterances. Flat resolves the leading clause on 2 of them, domain on all 6. + +Hierarchical stage-1 routing: **127 / 139 match cases (91%)** routed to the +correct domain. + +## Reading the numbers + +**The accuracy ordering is something this dataset was built to produce, not a +property of the engines.** The three discriminating sections each move the +result a fixed amount, so the headline ranking is whatever their proportions +make it. The dataset can be tuned to crown any engine: + +- **To make hierarchical win** — add more bare-keyword non-commands + (`"they cancel each other out"`). Each is a false positive flat and domain + emit and hierarchical's gate suppresses. The current dataset has ten; doubling + them widens hierarchical's lead. +- **To make hierarchical lose** — add more routing-hard commands: one-word + utterances, or commands carrying a long off-domain word that outweighs the + intent keyword in the stage-1 classifier. Each is an unrecoverable misroute, + a false negative only hierarchical suffers. +- **To separate flat from domain** — add more two-clause utterances. Domain + resolves the leading clause more often, so each case widens domain's margin. + Remove them and flat and domain are once again indistinguishable. +- **To erase all differences** — keep only clean single-intent commands with + distinctive per-domain vocabulary. Dilution cannot reorder an uncontested + winner, so all three topologies score identically. An earlier revision of + this dataset did exactly that and reported flat ≡ domain ≡ hierarchical. + +The same freedom applies to the vocabulary: sharing a keyword across domains +(`temperature` here is both a weather and a climate word) manufactures +cross-domain competition; keeping every object word domain-unique removes it. +Optional slots, keyword lengths, and how intents are grouped into domains all +shift the confidence arithmetic. + +So treat the table as a description of *behaviour* — flat dilutes most, domain +dilutes less, hierarchical gates false positives at the cost of misroutes — not +as a measurement of accuracy. A real accuracy figure requires a corpus sampled +from production traffic, with the section mix reflecting how often each +situation actually occurs. This dataset makes no such claim. + +## Interpreting the divergences + +**Flat and domain are not equivalent.** They agree on every clean single-intent +utterance — correctly, since dilution cannot reorder an uncontested winner — +but diverge on the two-clause cases. There, flat scores both clauses inside one +clique whose tag count dilutes them equally, and the tie falls to the +higher-coverage clause. Domain scores each clause in its own sub-engine, where +the leading clause's intent stands alone and is diluted less. + +**Hierarchical trades recall for precision.** Its stage-1 gate suppresses false +positives (a no-match utterance carrying a bare `stop` / `cancel` is routed to +a two-slot domain where nothing fires) but cannot recover a real command the +classifier misroutes. Whether that trade is net positive depends entirely on +how command-like the no-match traffic is — i.e. on the dataset. + +**Routing must be reliable for two-stage to pay off.** Every stage-1 misroute +is an unrecoverable error. Two-stage is only viable when domain vocabularies +are distinctive enough to classify. + +**Latency.** Flat is fastest (~0.2 ms). Hierarchical (~0.3 ms) runs a cheap +classifier plus one sub-engine. The parallel domain engine (~0.8 ms) evaluates +every sub-engine per query. All are far below any perceptible threshold. + +## How to run + +```bash +python benchmark/compare.py +``` diff --git a/docs/configuration.md b/docs/configuration.md index d61a8f9..c5b901b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,15 +1,20 @@ # Configuration -## Entry point +## Entry points -The plugin is published under the OPM `opm.pipeline` entry point: +The package publishes three OPM `opm.pipeline` entry points — the flat plugin +and two domain-organised variants: ```toml [project.entry-points."opm.pipeline"] "ovos-adapt-pipeline-plugin" = "ovos_adapt.opm:AdaptPipeline" +"ovos-adapt-domain-pipeline-plugin" = "ovos_adapt.opm:DomainAdaptPipeline" +"ovos-adapt-hierarchical-pipeline-plugin" = "ovos_adapt.opm:HierarchicalAdaptPipeline" ``` -OVOS discovers it by that id — `ovos-adapt-pipeline-plugin`. +OVOS discovers each by its id. This page covers the flat +`ovos-adapt-pipeline-plugin`; the variants behave the same way and accept the +same keys in their own config sections — see [Pipeline variants](pipelines.md). ## Enabling it in the pipeline diff --git a/docs/index.md b/docs/index.md index 9abd94e..dba22e4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,8 +20,10 @@ start at [Concepts](concepts.md). If you just want it running, jump to the **Building on or extending the plugin:** -5. [Bus protocol](bus-protocol.md) — the messagebus API skills use to register -6. [Internals](internals.md) — the tagger, clique expansion, the confidence math +5. [Pipeline variants](pipelines.md) — the flat, domain, and hierarchical plugins +6. [Bus protocol](bus-protocol.md) — the messagebus API skills use to register +7. [Internals](internals.md) — the tagger, clique expansion, the confidence math +8. [Engine comparison reference](benchmark.md) — how the variants diverge ## At a glance diff --git a/docs/internals.md b/docs/internals.md index 3eda308..d613405 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -90,18 +90,28 @@ Add an unrelated registered word — say `play` — and a fourth tag appears. though the command is unchanged. This dilution is the main reason scores differ between engine topologies (see below). -## DomainIntentDeterminationEngine - -`DomainIntentDeterminationEngine` groups intents into **domains**, each backed -by its own `IntentDeterminationEngine` — its own Trie, tagger, and parser set. -Registration takes a `domain=` argument; `determine_intent` scores every domain -and the caller takes the global argmax. - -Because each domain tags against only its own vocabulary, a domain's cliques -carry fewer foreign tags, so the `len(tags)` dilution above is smaller than in a -single flat engine. On a clean single-intent utterance this changes the score -but not the winner; on utterances carrying several intents' keywords it can -change which intent wins. +## Domain engines + +Two engine classes group intents into **domains** — each domain backed by its +own `IntentDeterminationEngine` (its own Trie, tagger, and parser set). +Registration takes a `domain=` argument. + +**`DomainIntentDeterminationEngine`** scores every domain and the caller takes +the global argmax. Because each domain tags against only its own vocabulary, a +domain's cliques carry fewer foreign tags, so the `len(tags)` dilution above is +smaller than in a single flat engine. On a clean single-intent utterance this +changes the score but not the winner; on utterances carrying several intents' +keywords it can change which intent wins. + +**`HierarchicalIntentDeterminationEngine`** subclasses the above and keeps the +same registration API. Its `determine_intent` is two-stage: a `classify_domain` +keyword-coverage classifier picks a single domain, then only that domain's +sub-engine is scored. A misrouted domain cannot be recovered. + +The `DomainAdaptPipeline` and `HierarchicalAdaptPipeline` plugins wrap these two +engines — see [Pipeline variants](pipelines.md). The +[engine comparison reference](benchmark.md) measures how the three topologies +diverge and explains why. ## Context diff --git a/docs/pipelines.md b/docs/pipelines.md new file mode 100644 index 0000000..020bdae --- /dev/null +++ b/docs/pipelines.md @@ -0,0 +1,63 @@ +# Pipeline variants + +The package ships **three** OPM pipeline plugins. All match intents with the +same Adapt engine and expose the same bus protocol; they differ only in how +intents are *organised* and *scored*. + +| Plugin class | Entry-point id | Config section | +|---|---|---| +| `AdaptPipeline` | `ovos-adapt-pipeline-plugin` | `intents.ovos-adapt-pipeline-plugin` | +| `DomainAdaptPipeline` | `ovos-adapt-domain-pipeline-plugin` | `intents.ovos_adapt_domain_pipeline` | +| `HierarchicalAdaptPipeline` | `ovos-adapt-hierarchical-pipeline-plugin` | `intents.ovos_adapt_hierarchical_pipeline` | + +Each is selected by adding its id (with a `-high` / `-medium` / `-low` tier +suffix) to `intents.pipeline` — see [Configuration](configuration.md). They can +coexist; each reads its own config section. + +## flat — `AdaptPipeline` + +One `IntentDeterminationEngine`. Every intent parser and every entity share a +single Trie and tagger. This is the default and the right choice for almost all +installs. + +## domain — `DomainAdaptPipeline` + +A `DomainIntentDeterminationEngine`. Intents are grouped into **domains** — one +per `skill_id`, taken from the `skill_id:intent_name` label — and each domain +gets its own isolated sub-engine (its own Trie and tagger). At match time every +domain is scored and the global argmax wins. + +Isolating each skill's vocabulary in its own Trie means a domain's parses carry +fewer foreign tags. Because Adapt's confidence divides by the total tag count of +a parse (see [Internals](internals.md)), less foreign vocabulary means less +dilution. On a clean single-intent command this changes the score but not the +winner; it can matter on utterances that carry several skills' keywords. + +## hierarchical — `HierarchicalAdaptPipeline` + +A `HierarchicalIntentDeterminationEngine` — the same per-skill domain model as +above, but two-stage. A stage-1 keyword-coverage classifier picks **one** domain +first, then only that domain's sub-engine is scored. A misrouted domain cannot +be recovered, so this trades recall (a wrong route loses the command) for +precision (a stray keyword routed to an unrelated domain triggers nothing). + +## Which should I use? + +Use the **flat** `AdaptPipeline` unless you have a specific reason not to. The +domain and hierarchical variants change *how* matching is organised, not its +correctness; whether they help depends on your skill mix and how command-like +your false traffic is. The differences — and how they can be measured or +misrepresented — are covered in detail in the +[engine comparison reference](benchmark.md). + +## Engine classes + +All three pipelines are thin wrappers over engine classes in +`ovos_adapt.engine`, which can be used standalone: + +- `IntentDeterminationEngine` — flat +- `DomainIntentDeterminationEngine` — per-domain, parallel argmax +- `HierarchicalIntentDeterminationEngine` — per-domain, two-stage routing + +See [Internals](internals.md) and [Quickstart](quickstart.md) for direct engine +use. diff --git a/ovos_adapt/engine.py b/ovos_adapt/engine.py index ba20154..26f3e02 100644 --- a/ovos_adapt/engine.py +++ b/ovos_adapt/engine.py @@ -535,3 +535,80 @@ def drop_regex_entity(self, domain, entity_type=None, match_func=None): """ return self.domains[domain].drop_regex_entity(entity_type=entity_type, match_func=match_func) + + +class HierarchicalIntentDeterminationEngine(DomainIntentDeterminationEngine): + """Two-stage domain engine: classify the domain, then resolve the intent. + + Shares the registration API of :class:`DomainIntentDeterminationEngine`. + Where that engine scores every domain in parallel and takes the global + argmax, :meth:`determine_intent` here first picks a single domain with a + keyword-coverage classifier and evaluates only that domain's sub-engine. + A misclassified domain cannot be recovered. + """ + + def __init__(self): + super().__init__() + #: domain -> set of registered entity surface forms, scored by the + #: stage-1 classifier. + self._domain_vocabulary = {} + + def register_entity(self, entity_value, entity_type, alias_of=None, + domain=0): + """Register an entity and record its surface form for the classifier.""" + super().register_entity(entity_value, entity_type, + alias_of=alias_of, domain=domain) + self._domain_vocabulary.setdefault(domain, set()).add( + entity_value.lower()) + + def classify_domain(self, utterance): + """Return the domain whose vocabulary best covers the utterance. + + Scores each domain by the number of utterance characters covered by + its registered entity values, word-boundary matched. + + Args: + utterance(str): the utterance to classify. + + Returns: + The best-matching domain, or ``None`` when no domain keyword is + present. + """ + text = utterance.lower() + best_domain = None + best_score = 0 + for domain, vocabulary in self._domain_vocabulary.items(): + if domain not in self.domains: + continue + covered = bytearray(len(text)) + for keyword in vocabulary: + for match in re.finditer( + r"\b" + re.escape(keyword) + r"\b", text): + for i in range(match.start(), match.end()): + covered[i] = 1 + score = sum(covered) + if score > best_score: + best_score = score + best_domain = domain + return best_domain + + def determine_intent(self, utterance, num_results=1, include_tags=False, + context_manager=None): + """Classify the domain, then yield intents from that domain only. + + Args: + utterance(str): an ascii or unicode string representing natural + language speech. + num_results(int): a maximum number of results to be returned. + include_tags(bool): include the parsed tags as part of result. + context_manager(list): a context manager to provide context. + + Returns: A generator that yields dictionaries. + """ + domain = self.classify_domain(utterance) + if domain is None or domain not in self.domains: + return + for intent in self.domains[domain].determine_intent( + utterance=utterance, num_results=num_results, + include_tags=include_tags, context_manager=context_manager): + yield intent diff --git a/ovos_adapt/opm.py b/ovos_adapt/opm.py index b684663..9d2995a 100644 --- a/ovos_adapt/opm.py +++ b/ovos_adapt/opm.py @@ -30,7 +30,9 @@ from ovos_utils.log import LOG from ovos_workshop.intents import open_intent_envelope -from ovos_adapt.engine import IntentDeterminationEngine +from ovos_adapt.engine import (IntentDeterminationEngine, + DomainIntentDeterminationEngine, + HierarchicalIntentDeterminationEngine) def _entity_skill_id(skill_id): @@ -396,3 +398,252 @@ def handle_vocab_manifest(self, message): """ self.bus.emit(message.reply("intent.service.adapt.vocab.manifest", {"vocab": self.registered_vocab})) + + +def _domain_from_intent_name(intent_name: str) -> str: + """Extract skill_id domain from an intent label. + + Intent labels follow the ``skill_id:intent_name`` convention. If no + ``:`` is present the full label is used as the domain. + """ + if not intent_name: + return "" + return intent_name.split(":", 1)[0] if ":" in intent_name else intent_name + + +class DomainAdaptPipeline(AdaptPipeline): + """Adapt pipeline backed by ``DomainIntentDeterminationEngine``. + + Unlike :class:`AdaptPipeline`, this variant maintains a dedicated + per-skill ``IntentDeterminationEngine`` (a "domain"). At match time, + every domain is scored in parallel and a global ``nlargest`` selects + the winner — no top-level router is involved. + + Intent registrations are routed to the right domain based on the + ``skill_id`` prefix of the intent label (``skill_id:intent_name``). + """ + + #: per-domain engine class; overridden by HierarchicalAdaptPipeline. + _engine_class = DomainIntentDeterminationEngine + #: config section under ``intents``; overridden by subclasses. + _config_key = "ovos_adapt_domain_pipeline" + + def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None, + config: Optional[Dict] = None): + core_config = Configuration() + # Use dedicated config section so users can tune this pipeline + # independently from the flat AdaptPipeline. + config = config or core_config.get("intents", {}).get( + self._config_key, {}) + # Skip AdaptPipeline.__init__ to avoid building a flat engine; call + # the grandparent initializer directly. + ConfidenceMatcherPipeline.__init__(self, bus, config) + self.lang = standardize_lang_tag(core_config.get("lang", "en-US")) + langs = core_config.get('secondary_langs') or [] + if self.lang not in langs: + langs.append(self.lang) + langs = [standardize_lang_tag(l) for l in langs] + self.engines = {lang: self._engine_class() + for lang in langs} + + self.lock = Lock() + self.registered_vocab = [] + self.max_words = 50 + + self.conf_high = self.config.get("conf_high") or 0.65 + self.conf_med = self.config.get("conf_med") or 0.45 + self.conf_low = self.config.get("conf_low") or 0.25 + + # Maps lang -> entity_type_prefix -> domain (skill_id). Allows the + # vocab/regex registration handlers, which only see entity_type, to + # route to the correct domain. + self._entity_domain_index: Dict[str, Dict[str, str]] = { + lang: {} for lang in langs + } + + self.bus.on('register_vocab', self.handle_register_vocab) + self.bus.on('register_intent', self.handle_register_intent) + self.bus.on('detach_intent', self.handle_detach_intent) + self.bus.on('detach_skill', self.handle_detach_skill) + + self.bus.on('intent.service.adapt.get', self.handle_get_adapt) + self.bus.on('intent.service.adapt.manifest.get', self.handle_adapt_manifest) + self.bus.on('intent.service.adapt.vocab.manifest.get', self.handle_vocab_manifest) + + def _resolve_entity_domain(self, lang: str, entity_type: str) -> str: + """Best-effort lookup of the domain that owns an entity_type. + + Vocab/regex registrations don't carry ``skill_id`` directly; we map + them by entity_type prefix populated when intents are registered. + Falls back to the entity_type itself if no match is found. + """ + index = self._entity_domain_index.get(lang, {}) + # exact match first + if entity_type in index: + return index[entity_type] + # longest-prefix match (entity_type often == "") + best = "" + for prefix, domain in index.items(): + if entity_type.startswith(prefix) and len(prefix) > len(best): + best = prefix + if best: + return index[best] + return entity_type + + def _gather_candidates(self, engine, utt, sess): + """Collect intent candidates for an utterance. + + Scores every domain sub-engine in parallel. Overridden by + :class:`HierarchicalAdaptPipeline` to score a single routed domain. + """ + with self.lock: + sub_engines = list(engine.domains.values()) + candidates = [] + for sub in sub_engines: + for it in sub.determine_intent( + utterance=utt, num_results=1, include_tags=True, + context_manager=sess.context): + candidates.append(it) + return candidates + + @lru_cache(maxsize=3) + def match_intent(self, utterances: Iterable[str], + lang: Optional[str] = None, + message: Optional[str] = None): + """Run all per-domain engines in parallel, take the global argmax. + + ``DomainIntentDeterminationEngine.determine_intent`` does not + propagate ``include_tags``/``context_manager`` to its sub-engines, + so we iterate sub-engines manually to preserve adapt's contextual + scoring behaviour. + """ + if message: + message = Message.deserialize(message) + sess = SessionManager.get(message) + + utterances = flatten_list(utterances) + utterances = [u for u in utterances if len(u.split()) < self.max_words] + if not utterances: + LOG.error(f"utterance exceeds max size of {self.max_words} words, skipping adapt match") + return None + + lang = self._get_closest_lang(lang) + if lang is None: + return None + + best_intent = {} + + def take_best(intent, utt): + nonlocal best_intent + best = best_intent.get('confidence', 0.0) if best_intent else 0.0 + conf = intent.get('confidence', 0.0) + skill = intent['intent_type'].split(":")[0] + if best < conf and intent["intent_type"] not in sess.blacklisted_intents \ + and skill not in sess.blacklisted_skills: + best_intent = intent + best_intent['utterance'] = utt + + engine = self.engines[lang] + for utt in utterances: + try: + candidates = self._gather_candidates(engine, utt, sess) + if candidates: + utt_best = max(candidates, + key=lambda x: x.get('confidence', 0.0)) + take_best(utt_best, utt) + except Exception as err: + LOG.exception(err) + + if best_intent: + ents = [tag['entities'][0] for tag in best_intent['__tags__'] + if 'entities' in tag] + sess.context.update_context(ents) + skill_id = best_intent['intent_type'].split(":")[0] + return IntentHandlerMatch( + match_type=best_intent['intent_type'], + match_data=best_intent, skill_id=skill_id, + utterance=best_intent['utterance'] + ) + return None + + def register_intent(self, intent): + """Register a new intent with the per-domain engine.""" + domain = _domain_from_intent_name(intent.name) + # Track entity_type prefix -> domain so vocab registrations can + # be routed to the same engine. + norm = _entity_skill_id(domain + ".") # mimic skill_id formatting + for lang in self.engines: + with self.lock: + self.engines[lang].register_intent_parser(intent, domain=domain) + self._entity_domain_index[lang][norm] = domain + + def register_vocabulary(self, entity_value: str, entity_type: str, + alias_of: str, regex_str: str, lang: str): + """Register skill vocabulary, routed by entity_type to a domain.""" + lang = standardize_lang_tag(lang) + if lang in self.engines: + with self.lock: + domain = self._resolve_entity_domain(lang, entity_type) + if regex_str: + self.engines[lang].register_regex_entity( + regex_str, domain=domain) + else: + self.engines[lang].register_entity( + entity_value, entity_type, alias_of=alias_of, + domain=domain) + + def detach_skill(self, skill_id): + """Drop the whole domain for a skill.""" + with self.lock: + for lang in self.engines: + if skill_id in self.engines[lang].domains: + del self.engines[lang].domains[skill_id] + # also drop any entity prefix index entries pointing here + idx = self._entity_domain_index.get(lang, {}) + for prefix in [p for p, d in idx.items() if d == skill_id]: + idx.pop(prefix, None) + + def detach_intent(self, intent_name): + """Detach a single intent from its owning domain.""" + domain = _domain_from_intent_name(intent_name) + with self.lock: + for lang in self.engines: + engine = self.engines[lang] + if domain in engine.domains: + sub = engine.domains[domain] + sub.intent_parsers = [p for p in sub.intent_parsers + if p.name != intent_name] + + def shutdown(self): + with self.lock: + for lang in self.engines: + self.engines[lang].domains = {} + + @property + def registered_intents(self): + lang = get_message_lang() + out = [] + for sub in self.engines[lang].domains.values(): + out.extend(parser.__dict__ for parser in sub.intent_parsers) + return out + + +class HierarchicalAdaptPipeline(DomainAdaptPipeline): + """Adapt pipeline backed by ``HierarchicalIntentDeterminationEngine``. + + Shares the per-skill domain model and registration routing of + :class:`DomainAdaptPipeline`. Unlike that pipeline, which scores every + domain in parallel, this variant classifies the domain first and + evaluates only that domain's sub-engine. A misclassified domain cannot + be recovered. + """ + + _engine_class = HierarchicalIntentDeterminationEngine + _config_key = "ovos_adapt_hierarchical_pipeline" + + def _gather_candidates(self, engine, utt, sess): + """Collect intent candidates from the single routed domain.""" + with self.lock: + return list(engine.determine_intent( + utterance=utt, num_results=1, include_tags=True, + context_manager=sess.context)) diff --git a/pyproject.toml b/pyproject.toml index ea1b3b2..5172d1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,8 @@ Homepage = "https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin" [project.entry-points."opm.pipeline"] "ovos-adapt-pipeline-plugin" = "ovos_adapt.opm:AdaptPipeline" +"ovos-adapt-domain-pipeline-plugin" = "ovos_adapt.opm:DomainAdaptPipeline" +"ovos-adapt-hierarchical-pipeline-plugin" = "ovos_adapt.opm:HierarchicalAdaptPipeline" [tool.setuptools.packages.find] where = ["."] diff --git a/test/test_domain_pipeline.py b/test/test_domain_pipeline.py new file mode 100644 index 0000000..14dfb33 --- /dev/null +++ b/test/test_domain_pipeline.py @@ -0,0 +1,94 @@ +# Copyright 2026 Open Voice OS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +"""Tests for the per-domain Adapt pipeline. + +These tests ensure that ``DomainAdaptPipeline`` registers each skill's +intents into a dedicated sub-engine (a "domain") and that +``match_intent`` picks the correct skill regardless of how many domains +are registered. +""" +from unittest import TestCase, mock + +from ovos_bus_client.message import Message +from ovos_workshop.intents import IntentBuilder + +from ovos_adapt.engine import DomainIntentDeterminationEngine +from ovos_adapt.opm import DomainAdaptPipeline + + +def _vocab_msg(keyword, value): + return Message('register_vocab', + {'entity_value': value, 'entity_type': keyword}) + + +class TestDomainAdaptPipeline(TestCase): + + def setUp(self): + self.pipeline = DomainAdaptPipeline(mock.Mock()) + + # Skill A: weather + intent_a = (IntentBuilder('weather.skill:WeatherIntent') + .require('weather_skillWeatherKeyword')) + # Skill B: music + intent_b = (IntentBuilder('music.skill:PlayIntent') + .require('music_skillPlayKeyword')) + + # Register intents first so the entity_type prefix index is + # populated before vocab arrives. + self.pipeline.handle_register_intent( + Message('register_intent', intent_a.__dict__)) + self.pipeline.handle_register_intent( + Message('register_intent', intent_b.__dict__)) + + self.pipeline.handle_register_vocab( + _vocab_msg('weather_skillWeatherKeyword', 'weather')) + self.pipeline.handle_register_vocab( + _vocab_msg('music_skillPlayKeyword', 'play')) + + def test_engine_is_domain_engine(self): + for engine in self.pipeline.engines.values(): + self.assertIsInstance(engine, DomainIntentDeterminationEngine) + + def test_two_domains_registered(self): + lang = self.pipeline.lang + engine = self.pipeline.engines[lang] + self.assertIn('weather.skill', engine.domains) + self.assertIn('music.skill', engine.domains) + # Each domain only owns its own intent parser. + weather_names = [p.name for p in + engine.domains['weather.skill'].intent_parsers] + music_names = [p.name for p in + engine.domains['music.skill'].intent_parsers] + self.assertEqual(weather_names, ['weather.skill:WeatherIntent']) + self.assertEqual(music_names, ['music.skill:PlayIntent']) + + def test_match_routes_to_weather_domain(self): + match = self.pipeline.match_intent(('weather',), self.pipeline.lang, None) + self.assertIsNotNone(match) + self.assertEqual(match.match_type, 'weather.skill:WeatherIntent') + self.assertEqual(match.skill_id, 'weather.skill') + + def test_match_routes_to_music_domain(self): + match = self.pipeline.match_intent(('play',), self.pipeline.lang, None) + self.assertIsNotNone(match) + self.assertEqual(match.match_type, 'music.skill:PlayIntent') + self.assertEqual(match.skill_id, 'music.skill') + + def test_detach_skill_drops_domain(self): + self.pipeline.detach_skill('weather.skill') + engine = self.pipeline.engines[self.pipeline.lang] + self.assertNotIn('weather.skill', engine.domains) + # The other domain is untouched. + self.assertIn('music.skill', engine.domains) + + def test_detach_intent_removes_only_that_intent(self): + self.pipeline.detach_intent('weather.skill:WeatherIntent') + engine = self.pipeline.engines[self.pipeline.lang] + names = [p.name for p in + engine.domains['weather.skill'].intent_parsers] + self.assertEqual(names, []) From d1a8139ac06a043988d787eca3b3c292984fe5aa Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 22 May 2026 16:29:47 +0000 Subject: [PATCH 16/38] Increment Version to 1.3.0a1 --- ovos_adapt/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 31f1fd3..680b83a 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,8 +1,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 -VERSION_MINOR = 2 +VERSION_MINOR = 3 VERSION_BUILD = 0 -VERSION_ALPHA = 2 +VERSION_ALPHA = 1 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") From 31b4ac88678d6820d834214bef88fa70fe959ebf Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 22 May 2026 16:30:06 +0000 Subject: [PATCH 17/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c139b..902de8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.3.0a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.0a1) (2026-05-22) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.2.0a2...1.3.0a1) + +**Merged pull requests:** + +- feat: HierarchicalIntentDeterminationEngine [\#37](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/37) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.2.0a2](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.2.0a2) (2026-05-22) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.2.0a1...1.2.0a2) From 237925a088bae4a99dcebbb236af6165ea456a5e Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 23 May 2026 16:11:12 +0100 Subject: [PATCH 18/38] feat!: bucket engines by full BCP-47 tag, migrate to ovos-spec-tools (#42) Replace ovos_utils.lang.standardize_lang_tag (macro=True by default) with ovos_spec_tools.standardize_lang, and replace the local langcodes.closest_match + score<10 reconciliation in _get_closest_lang with ovos_spec_tools.closest_lang. Engine buckets are now keyed by the full normalized BCP-47 tag (e.g. en-US) rather than the macro language (e.g. en). Registration handlers reconcile the incoming lang to an existing bucket via closest_lang, matching the policy used at match time. BREAKING CHANGE: adapt engines now bucket by full BCP-47 tag. Resource registrations whose lang differs from the configured pipeline lang (e.g. registering en-GB vocab into an en-US pipeline) previously collided by coincidence after macro-stripping and now go through the per-match closest_lang reconciliation; configurations that depended on the silent macro-collapse should add a matching entry to secondary_langs or rely on closest_lang's region tolerance. Co-authored-by: Claude Opus 4.7 (1M context) --- ovos_adapt/opm.py | 36 ++++++++++++++++-------------------- pyproject.toml | 1 + 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/ovos_adapt/opm.py b/ovos_adapt/opm.py index 9d2995a..72a83f7 100644 --- a/ovos_adapt/opm.py +++ b/ovos_adapt/opm.py @@ -17,16 +17,15 @@ from threading import Lock from typing import List, Optional, Iterable, Union, Dict -from langcodes import closest_match from ovos_bus_client.client import MessageBusClient from ovos_bus_client.message import Message from ovos_bus_client.session import SessionManager from ovos_bus_client.util import get_message_lang from ovos_config.config import Configuration from ovos_plugin_manager.templates.pipeline import IntentHandlerMatch, ConfidenceMatcherPipeline +from ovos_spec_tools import closest_lang, standardize_lang from ovos_utils import flatten_list from ovos_utils.fakebus import FakeBus -from ovos_utils.lang import standardize_lang_tag from ovos_utils.log import LOG from ovos_workshop.intents import open_intent_envelope @@ -58,11 +57,11 @@ def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None, core_config = Configuration() config = config or core_config.get("context", {}) # legacy mycroft-core path super().__init__(bus, config) - self.lang = standardize_lang_tag(core_config.get("lang", "en-US")) + self.lang = standardize_lang(core_config.get("lang", "en-US")) langs = core_config.get('secondary_langs') or [] if self.lang not in langs: langs.append(self.lang) - langs = [standardize_lang_tag(l) for l in langs] + langs = [standardize_lang(l) for l in langs] self.engines = {lang: IntentDeterminationEngine() for lang in langs} @@ -217,14 +216,7 @@ def take_best(intent, utt): def _get_closest_lang(self, lang: str) -> Optional[str]: if self.engines: - lang = standardize_lang_tag(lang) - closest, score = closest_match(lang, list(self.engines.keys())) - # https://langcodes-hickford.readthedocs.io/en/sphinx/index.html#distance-values - # 0 -> These codes represent the same language, possibly after filling in values and normalizing. - # 1- 3 -> These codes indicate a minor regional difference. - # 4 - 10 -> These codes indicate a significant but unproblematic regional difference. - if score < 10: - return closest + return closest_lang(lang, list(self.engines.keys())) return None def register_vocabulary(self, entity_value: str, entity_type: str, @@ -240,8 +232,8 @@ def register_vocabulary(self, entity_value: str, entity_type: str, entity_type: the type/tag of an entity instance alias_of: entity this is an alternative for """ - lang = standardize_lang_tag(lang) - if lang in self.engines: + lang = self._get_closest_lang(lang) + if lang is not None: with self.lock: if regex_str: self.engines[lang].register_regex_entity(regex_str) @@ -323,7 +315,9 @@ def shutdown(self): @property def registered_intents(self): - lang = get_message_lang() + lang = self._get_closest_lang(get_message_lang()) + if lang is None: + return [] return [parser.__dict__ for parser in self.engines[lang].intent_parsers] def handle_register_vocab(self, message): @@ -438,11 +432,11 @@ def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None, # Skip AdaptPipeline.__init__ to avoid building a flat engine; call # the grandparent initializer directly. ConfidenceMatcherPipeline.__init__(self, bus, config) - self.lang = standardize_lang_tag(core_config.get("lang", "en-US")) + self.lang = standardize_lang(core_config.get("lang", "en-US")) langs = core_config.get('secondary_langs') or [] if self.lang not in langs: langs.append(self.lang) - langs = [standardize_lang_tag(l) for l in langs] + langs = [standardize_lang(l) for l in langs] self.engines = {lang: self._engine_class() for lang in langs} @@ -580,8 +574,8 @@ def register_intent(self, intent): def register_vocabulary(self, entity_value: str, entity_type: str, alias_of: str, regex_str: str, lang: str): """Register skill vocabulary, routed by entity_type to a domain.""" - lang = standardize_lang_tag(lang) - if lang in self.engines: + lang = self._get_closest_lang(lang) + if lang is not None: with self.lock: domain = self._resolve_entity_domain(lang, entity_type) if regex_str: @@ -621,7 +615,9 @@ def shutdown(self): @property def registered_intents(self): - lang = get_message_lang() + lang = self._get_closest_lang(get_message_lang()) + if lang is None: + return [] out = [] for sub in self.engines[lang].domains.values(): out.extend(parser.__dict__ for parser in sub.intent_parsers) diff --git a/pyproject.toml b/pyproject.toml index 5172d1c..f3d5af9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "ovos-plugin-manager>=0.5.0,<3.0.0", "ovos-workshop>=0.1.7,<9.0.0", "ovos-utils>=0.3.4,<1.0.0", + "ovos-spec-tools>=0.1.0,<1.0.0", "langcodes", ] From 60e93d5e1212ece0aca71dd24b0f31ff36345f6e Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 23 May 2026 15:11:24 +0000 Subject: [PATCH 19/38] Increment Version to 1.3.1a1 --- ovos_adapt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 680b83a..bc2da42 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 VERSION_MINOR = 3 -VERSION_BUILD = 0 +VERSION_BUILD = 1 VERSION_ALPHA = 1 # END_VERSION_BLOCK From ec42631c402b2030fceefc5a6a3db4eea3d22914 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 23 May 2026 15:11:49 +0000 Subject: [PATCH 20/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 902de8c..d7fb3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.3.1a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.1a1) (2026-05-23) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.0a1...1.3.1a1) + +**Merged pull requests:** + +- fix: bucket engines by full BCP-47 tag, migrate to ovos-spec-tools [\#42](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/42) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.3.0a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.0a1) (2026-05-22) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.2.0a2...1.3.0a1) From 509a034fe9372090d3cde590acb33f5271850487 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:11:39 +0100 Subject: [PATCH 21/38] fix(deps): allow ovos-workshop 9.x (widen <9.0.0 -> <10.0.0) (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): allow ovos-workshop 9.x (widen <9.0.0 -> <10.0.0) Co-Authored-By: Claude Opus 4.8 * fix(deps): drop ovos-workshop dependency The adapt parser vendored its matching Intent/IntentBuilder/open_intent_envelope from ovos-workshop, which forced an ovos-workshop version cap and coupled this parser to a higher-level package. These primitives are native to the adapt parser (the engine calls Intent.validate_with_tags), so they now live in ovos_adapt.intent directly. - ovos_adapt/intent.py: own the full matching Intent + IntentBuilder (with excludes support) and open_intent_envelope instead of importing from ovos_workshop.intents - ovos_adapt/opm.py: import open_intent_envelope from ovos_adapt.intent - tests: import IntentBuilder/Intent from ovos_adapt.intent - pyproject: remove ovos-workshop dependency entirely (no cap to widen) ovos-spec-tools (closest_lang/standardize_lang) is unaffected. Co-Authored-By: Claude Opus 4.8 * refactor: consume ovos-spec-tools intent primitives instead of duplicating ovos_adapt.intent re-implemented the declarative INTENT-4 Intent / IntentBuilder / open_intent_envelope from scratch even though ovos-spec-tools (already a declared dependency) provides the canonical versions. Only the adapt-engine tag-matching logic is genuinely adapt-specific. - Intent now subclasses ovos_spec_tools.intent.Intent, inheriting the name/requires/at_least_one/optional/excludes role lists and to_keyword_payload emission; it adds ONLY validate / validate_with_tags (plus the private tag-search helpers). adapt Intent instances are now isinstance(spec_tools.Intent) and expose the INTENT-4 keyword-payload surface. - IntentBuilder subclasses ovos_spec_tools.intent.IntentBuilder and overrides only build() to return adapt's matching Intent. - open_intent_envelope reuses the spec-tools parser (which accepts both legacy and INTENT-4 §5.2 wire keys) and re-wraps as adapt's Intent. - Module helpers (is_entity, find_first_tag, find_next_tag, choose_1_from_each, resolve_one_of) are unchanged. - Floor ovos-spec-tools to >=0.16.0a1 (first release carrying the consumed intent primitives). spec-tools' Intent was cleanly subclassable: plain class (no slots/ frozen), constructor signature-compatible, and its field normalization ((type, attr) pairs for requires/optional, name tuples for at_least_one, bare names for excludes) is exactly what adapt's validate_with_tags reads. No spec-tools change required. All 93 tests pass unchanged. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- ovos_adapt/intent.py | 217 ++++++++++++++++++++++++++++++++++- ovos_adapt/opm.py | 2 +- pyproject.toml | 3 +- test/test_domain_pipeline.py | 2 +- test/test_intent_service.py | 2 +- test/test_ovoscope_e2e.py | 2 +- 6 files changed, 220 insertions(+), 8 deletions(-) diff --git a/ovos_adapt/intent.py b/ovos_adapt/intent.py index 5848f92..838ed0b 100644 --- a/ovos_adapt/intent.py +++ b/ovos_adapt/intent.py @@ -16,7 +16,221 @@ __author__ = 'seanfitz' import itertools -from ovos_workshop.intents import Intent, IntentBuilder + +from ovos_spec_tools.intent import Intent as _SpecIntent +from ovos_spec_tools.intent import IntentBuilder as _SpecIntentBuilder +from ovos_spec_tools.intent import open_intent_envelope as _spec_open_intent_envelope + +CLIENT_ENTITY_NAME = 'Client' + + +class Intent(_SpecIntent): + """An adapt intent parser carrying its own matching logic. + + This is the adapt engine's matching primitive: every parser registered + with :class:`~ovos_adapt.engine.IntentDeterminationEngine` must expose + :meth:`validate` / :meth:`validate_with_tags`. + + The **declarative** half — the ``name`` / ``requires`` / ``at_least_one`` / + ``optional`` / ``excludes`` role lists and :meth:`to_keyword_payload` + emission — is the canonical OVOS-INTENT-4 :class:`ovos_spec_tools.intent.Intent` + DTO, which this class subclasses rather than duplicates. Only the + adapt-engine **matching** logic (:meth:`validate` / + :meth:`validate_with_tags` and the private tag-search helpers) lives here. + + Subclasses of this remain ``isinstance`` of the spec-tools DTO, so anything + consuming the INTENT-4 definition surface (e.g. registration producers) + works unchanged on an adapt parser. + """ + + def validate(self, tags, confidence): + """Using this method removes tags from the result of validate_with_tags + + Returns: + intent(intent): Results from validate_with_tags + """ + intent, tags = self.validate_with_tags(tags, confidence) + return intent + + def validate_with_tags(self, tags, confidence): + """Validate whether tags has required entites for this intent to fire + + Args: + tags(list): Tags and Entities used for validation + confidence(float): The weight associate to the parse result, + as indicated by the parser. This is influenced by a parser + that uses edit distance or context. + + Returns: + intent, tags: Returns intent and tags used by the intent on + failure to meat required entities then returns intent with + confidence + of 0.0 and an empty list for tags. + """ + result = {'intent_type': self.name} + intent_confidence = 0.0 + local_tags = tags[:] + used_tags = [] + + # Check excludes first + for exclude_type in self.excludes: + exclude_tag, _canonical_form, _tag_confidence = \ + self._find_first_tag(local_tags, exclude_type) + if exclude_tag: + result['confidence'] = 0.0 + return result, [] + + for require_type, attribute_name in self.requires: + required_tag, canonical_form, tag_confidence = \ + self._find_first_tag(local_tags, require_type) + if not required_tag: + result['confidence'] = 0.0 + return result, [] + + result[attribute_name] = canonical_form + if required_tag in local_tags: + local_tags.remove(required_tag) + used_tags.append(required_tag) + intent_confidence += tag_confidence + + if len(self.at_least_one) > 0: + best_resolution = self._resolve_one_of(local_tags, self.at_least_one) + if not best_resolution: + result['confidence'] = 0.0 + return result, [] + else: + for key in best_resolution: + # TODO: at least one should support aliases + result[key] = best_resolution[key][0].get('key') + intent_confidence += 1.0 * best_resolution[key][0]['entities'][0].get('confidence', 1.0) + used_tags.append(best_resolution[key][0]) + if best_resolution in local_tags: + local_tags.remove(best_resolution[key][0]) + + for optional_type, attribute_name in self.optional: + optional_tag, canonical_form, tag_confidence = \ + self._find_first_tag(local_tags, optional_type) + if not optional_tag or attribute_name in result: + continue + result[attribute_name] = canonical_form + if optional_tag in local_tags: + local_tags.remove(optional_tag) + used_tags.append(optional_tag) + intent_confidence += tag_confidence + + total_confidence = (intent_confidence / len(tags) * confidence) \ + if tags else 0.0 + + target_client, canonical_form, confidence = \ + self._find_first_tag(local_tags, CLIENT_ENTITY_NAME) + + result['target'] = target_client.get('key') if target_client else None + result['confidence'] = total_confidence + + return result, used_tags + + @classmethod + def _resolve_one_of(cls, tags, at_least_one): + """Search through all combinations of at_least_one rules to find a + combination that is covered by tags + + Args: + tags(list): List of tags with Entities to search for Entities + at_least_one(list): List of Entities to find in tags + + Returns: + object: + returns None if no match is found but returns any match as an object + """ + for possible_resolution in itertools.product(*at_least_one): + resolution = {} + pr = possible_resolution[:] + for entity_type in pr: + last_end_index = -1 + if entity_type in resolution: + last_end_index = resolution[entity_type][-1].get('end_token') + tag, value, c = cls._find_first_tag(tags, entity_type, + after_index=last_end_index) + if not tag: + break + else: + if entity_type not in resolution: + resolution[entity_type] = [] + resolution[entity_type].append(tag) + # Check if this is a valid resolution (all one_of rules matched) + if len(resolution) == len(possible_resolution): + return resolution + + return None + + @staticmethod + def _find_first_tag(tags, entity_type, after_index=-1): + """Searches tags for entity type after given index + + Args: + tags(list): a list of tags with entity types to be compared to + entity_type + entity_type(str): This is he entity type to be looking for in tags + after_index(int): the start token must be greater than this. + + Returns: + ( tag, v, confidence ): + tag(str): is the tag that matched + v(str): ? the word that matched? + confidence(float): is a measure of accuracy. 1 is full confidence + and 0 is none. + """ + for tag in tags: + for entity in tag.get('entities'): + for v, t in entity.get('data'): + if t.lower() == entity_type.lower() and \ + (tag.get('start_token', 0) > after_index or + tag.get('from_context', False)): + return tag, v, entity.get('confidence') + + return None, None, None + + +class IntentBuilder(_SpecIntentBuilder): + """IntentBuilder, used to construct adapt intent parsers. + + Inherits the fluent ``require`` / ``optionally`` / ``one_of`` / ``exclude`` + role accumulation from the canonical OVOS-INTENT-4 + :class:`ovos_spec_tools.intent.IntentBuilder`. Only :meth:`build` is + overridden, so the produced object is adapt's matching :class:`Intent` + (which is itself a spec-tools ``Intent``) rather than the bare DTO. + + Notes: + This is designed to allow construction of intents in one line. + + Example: + IntentBuilder("Intent")\ + .require("A")\ + .one_of("C","D")\ + .optional("G").build() + """ + + def build(self): + """Constructs an adapt :class:`Intent` from the builder's specifications. + + :return: an Intent instance with adapt matching logic. + """ + return Intent(self.name, self.requires, + self.at_least_one, self.optional, + self.excludes) + + +def open_intent_envelope(message): + """Convert dictionary received over messagebus to adapt :class:`Intent`. + + Parses the envelope with the canonical spec-tools helper (which accepts + both the legacy and OVOS-INTENT-4 §5.2 wire keys) and re-wraps the result + as adapt's matching :class:`Intent`. + """ + spec_intent = _spec_open_intent_envelope(message) + return Intent(spec_intent.name, spec_intent.requires, + spec_intent.at_least_one, spec_intent.optional, + spec_intent.excludes) def is_entity(tag, entity_name): @@ -85,4 +299,3 @@ def resolve_one_of(tags, at_least_one): returns None if no match is found but returns any match as an object """ return Intent._resolve_one_of(tags, at_least_one) - diff --git a/ovos_adapt/opm.py b/ovos_adapt/opm.py index 72a83f7..7dbcb16 100644 --- a/ovos_adapt/opm.py +++ b/ovos_adapt/opm.py @@ -27,8 +27,8 @@ from ovos_utils import flatten_list from ovos_utils.fakebus import FakeBus from ovos_utils.log import LOG -from ovos_workshop.intents import open_intent_envelope +from ovos_adapt.intent import open_intent_envelope from ovos_adapt.engine import (IntentDeterminationEngine, DomainIntentDeterminationEngine, HierarchicalIntentDeterminationEngine) diff --git a/pyproject.toml b/pyproject.toml index f3d5af9..294e894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,8 @@ requires-python = ">=3.10" dependencies = [ "six>=1.10.0", "ovos-plugin-manager>=0.5.0,<3.0.0", - "ovos-workshop>=0.1.7,<9.0.0", "ovos-utils>=0.3.4,<1.0.0", - "ovos-spec-tools>=0.1.0,<1.0.0", + "ovos-spec-tools>=0.16.0a1,<1.0.0", "langcodes", ] diff --git a/test/test_domain_pipeline.py b/test/test_domain_pipeline.py index 14dfb33..2923968 100644 --- a/test/test_domain_pipeline.py +++ b/test/test_domain_pipeline.py @@ -15,7 +15,7 @@ from unittest import TestCase, mock from ovos_bus_client.message import Message -from ovos_workshop.intents import IntentBuilder +from ovos_adapt.intent import IntentBuilder from ovos_adapt.engine import DomainIntentDeterminationEngine from ovos_adapt.opm import DomainAdaptPipeline diff --git a/test/test_intent_service.py b/test/test_intent_service.py index 3c7dbab..c5c7237 100644 --- a/test/test_intent_service.py +++ b/test/test_intent_service.py @@ -15,7 +15,7 @@ from unittest import TestCase, mock from ovos_bus_client.message import Message -from ovos_workshop.intents import IntentBuilder, Intent as AdaptIntent +from ovos_adapt.intent import IntentBuilder, Intent as AdaptIntent from ovos_adapt.opm import AdaptPipeline diff --git a/test/test_ovoscope_e2e.py b/test/test_ovoscope_e2e.py index b8edd7d..144cb6b 100644 --- a/test/test_ovoscope_e2e.py +++ b/test/test_ovoscope_e2e.py @@ -19,7 +19,7 @@ register_adapt_intent, register_adapt_vocab, ) -from ovos_workshop.intents import IntentBuilder # noqa: E402 +from ovos_adapt.intent import IntentBuilder # noqa: E402 from ovos_adapt.opm import AdaptPipeline # noqa: E402 From 4a81bfde51a90984916321e2e824a4f415053ef8 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:11:52 +0000 Subject: [PATCH 22/38] Increment Version to 1.3.2a1 --- ovos_adapt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index bc2da42..6a99c27 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 VERSION_MINOR = 3 -VERSION_BUILD = 1 +VERSION_BUILD = 2 VERSION_ALPHA = 1 # END_VERSION_BLOCK From b839f9f89f0ab1ca301a3fe0c55112150700ea0b Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:12:20 +0000 Subject: [PATCH 23/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7fb3cc..4fb0643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.3.2a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.2a1) (2026-06-27) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.1a1...1.3.2a1) + +**Merged pull requests:** + +- fix\(deps\): allow ovos-workshop 9.x \(widen \<9.0.0 -\> \<10.0.0\) [\#45](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/45) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.3.1a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.1a1) (2026-05-23) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.0a1...1.3.1a1) From 15cdf8a09e0bd9547d222e8f7b9d415b7083211d Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:01:46 +0100 Subject: [PATCH 24/38] fix: guard None session blacklists in intent match filters (#47) ovos-bus-client 2.4.x (Session.blacklisted_intents/blacklisted_skills are optional SESSION-1 fields) can surface either blacklist as None, so the 'intent_type not in sess.blacklisted_intents' / 'skill not in sess.blacklisted_skills' membership tests in match_intent raised 'TypeError: argument of type NoneType is not iterable' and aborted every adapt match (the utterance then fell through to complete_intent_failure). Treat a None blacklist as empty. Co-authored-by: Claude Opus 4.8 --- ovos_adapt/opm.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ovos_adapt/opm.py b/ovos_adapt/opm.py index 7dbcb16..86d2d09 100644 --- a/ovos_adapt/opm.py +++ b/ovos_adapt/opm.py @@ -178,8 +178,8 @@ def take_best(intent, utt): best = best_intent.get('confidence', 0.0) if best_intent else 0.0 conf = intent.get('confidence', 0.0) skill = intent['intent_type'].split(":")[0] - if best < conf and intent["intent_type"] not in sess.blacklisted_intents \ - and skill not in sess.blacklisted_skills: + if best < conf and intent["intent_type"] not in (sess.blacklisted_intents or []) \ + and skill not in (sess.blacklisted_skills or []): best_intent = intent # TODO - Shouldn't Adapt do this? best_intent['utterance'] = utt @@ -532,8 +532,8 @@ def take_best(intent, utt): best = best_intent.get('confidence', 0.0) if best_intent else 0.0 conf = intent.get('confidence', 0.0) skill = intent['intent_type'].split(":")[0] - if best < conf and intent["intent_type"] not in sess.blacklisted_intents \ - and skill not in sess.blacklisted_skills: + if best < conf and intent["intent_type"] not in (sess.blacklisted_intents or []) \ + and skill not in (sess.blacklisted_skills or []): best_intent = intent best_intent['utterance'] = utt From c6b6eb6e1e3aabba84eebd79e79586782f1e6844 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:01:58 +0000 Subject: [PATCH 25/38] Increment Version to 1.3.3a1 --- ovos_adapt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 6a99c27..e01d8df 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 VERSION_MINOR = 3 -VERSION_BUILD = 2 +VERSION_BUILD = 3 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 54fda13803208b338a7ca351ab94886335d799ff Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:02:19 +0000 Subject: [PATCH 26/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb0643..4265335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.3.3a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.3a1) (2026-06-27) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.2a1...1.3.3a1) + +**Merged pull requests:** + +- fix: guard None session blacklists in intent match filters [\#47](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/47) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.3.2a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.2a1) (2026-06-27) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.1a1...1.3.2a1) From 2c9602816c4852d43b0abe3fb7bc58f2e486ff5e Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:50:45 +0100 Subject: [PATCH 27/38] fix: accept foreign (ovos-spec-tools / ovos-workshop) Intents at registration (#48) * fix: coerce foreign Intents at registration so direct registration works ovos-workshop re-exports the bare ovos-spec-tools IntentBuilder, whose Intent carries the same fields but lacks adapt's matching API (validate / validate_with_tags). Skills registering over the bus are unaffected (open_intent_envelope already rebuilds an adapt Intent from the serialized message), but direct in-process register_intent_parser of such an Intent raised ValueError. Rebuild any matching-API-less but intent-shaped parser as an adapt Intent before the interface check; native adapt Intents pass through unchanged and non-intents still raise. Co-Authored-By: Claude Opus 4.8 * fix: declare bus-client runtime floor + lift pm/spec-tools floors opm.py imports ovos_bus_client (MessageBusClient/SessionManager/Message) at runtime; declare it directly with the migration-capable prerelease floor (>=2.5.1a1,<3.0.0). That floor only resolves without --pre when the ovos-plugin-manager floor is also lifted to the 2.x line (>=2.4.0a1), otherwise pip selects an old pm that caps ovos-bus-client <2.0. Raise the ovos-spec-tools floor to the documented baseline 0.16.1a2. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- ovos_adapt/engine.py | 17 ++++++++++ pyproject.toml | 5 +-- test/test_register_foreign_intent.py | 48 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 test/test_register_foreign_intent.py diff --git a/ovos_adapt/engine.py b/ovos_adapt/engine.py index 26f3e02..0359a1a 100644 --- a/ovos_adapt/engine.py +++ b/ovos_adapt/engine.py @@ -22,6 +22,7 @@ from ovos_adapt.parser import Parser from ovos_adapt.tools.text.tokenizer import EnglishTokenizer from ovos_adapt.tools.text.trie import Trie +from ovos_adapt.intent import Intent __author__ = 'seanfitz' @@ -233,6 +234,22 @@ def register_intent_parser(self, intent_parser): Raises: ValueError: on invalid intent """ + # Intents built via the shared ovos-spec-tools primitive (e.g. + # ovos-workshop re-exporting IntentBuilder) carry the same fields but + # lack adapt's matching API (validate / validate_with_tags). Rebuild + # them as an adapt Intent so they can be matched. Skills registering + # over the bus already get this via open_intent_envelope; this covers + # direct in-process registration. + if not (hasattr(intent_parser, 'validate_with_tags') and + callable(getattr(intent_parser, 'validate_with_tags', None))): + if all(hasattr(intent_parser, attr) for attr in + ('name', 'requires', 'at_least_one', 'optional', 'excludes')): + intent_parser = Intent(intent_parser.name, + intent_parser.requires, + intent_parser.at_least_one, + intent_parser.optional, + intent_parser.excludes) + if hasattr(intent_parser, 'validate') and callable(intent_parser.validate): self.intent_parsers.append(intent_parser) else: diff --git a/pyproject.toml b/pyproject.toml index 294e894..bcebf15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,9 +24,10 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ "six>=1.10.0", - "ovos-plugin-manager>=0.5.0,<3.0.0", + "ovos-plugin-manager>=2.4.0a1,<3.0.0", "ovos-utils>=0.3.4,<1.0.0", - "ovos-spec-tools>=0.16.0a1,<1.0.0", + "ovos_bus_client>=2.5.1a1,<3.0.0", + "ovos-spec-tools>=0.16.1a2,<1.0.0", "langcodes", ] diff --git a/test/test_register_foreign_intent.py b/test/test_register_foreign_intent.py new file mode 100644 index 0000000..db6a54f --- /dev/null +++ b/test/test_register_foreign_intent.py @@ -0,0 +1,48 @@ +"""adapt's engine must accept Intents built outside adapt — e.g. the bare +ovos-spec-tools Intent that ovos-workshop re-exports via IntentBuilder — by +coercing them to an adapt Intent at registration. Skills registering over the +bus already get this via open_intent_envelope; this covers direct in-process +registration (library use / test harnesses).""" +import unittest + +from ovos_adapt.engine import IntentDeterminationEngine +from ovos_adapt.intent import IntentBuilder as AdaptBuilder +from ovos_spec_tools.intent import IntentBuilder as SpecBuilder + + +class TestRegisterForeignIntent(unittest.TestCase): + def test_spec_tools_intent_is_coerced(self): + engine = IntentDeterminationEngine() + foreign = SpecBuilder("LightIntent").require("LightKeyword").build() + # the bare spec-tools Intent lacks adapt's matching API + self.assertFalse(hasattr(foreign, "validate_with_tags")) + engine.register_intent_parser(foreign) # must not raise + self.assertEqual(len(engine.intent_parsers), 1) + # rebuilt as an adapt Intent — now matchable + self.assertTrue(hasattr(engine.intent_parsers[0], "validate_with_tags")) + + def test_foreign_intent_matches(self): + engine = IntentDeterminationEngine() + engine.register_entity("light", "LightKeyword") + engine.register_entity("on", "OnKeyword") + foreign = (SpecBuilder("LightIntent") + .require("OnKeyword").require("LightKeyword").build()) + engine.register_intent_parser(foreign) + intents = list(engine.determine_intent("turn on the light")) + self.assertTrue(intents) + self.assertEqual(intents[0].get("intent_type"), "LightIntent") + + def test_native_adapt_intent_passes_through_unchanged(self): + engine = IntentDeterminationEngine() + native = AdaptBuilder("NativeIntent").require("LightKeyword").build() + engine.register_intent_parser(native) + self.assertIs(engine.intent_parsers[0], native) + + def test_non_intent_still_rejected(self): + engine = IntentDeterminationEngine() + with self.assertRaises(ValueError): + engine.register_intent_parser("NOTAPARSER") + + +if __name__ == "__main__": + unittest.main() From 04b4dce7d9c9ac2a24b425fb882857d999440117 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:50:57 +0000 Subject: [PATCH 28/38] Increment Version to 1.3.4a1 --- ovos_adapt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index e01d8df..33380f4 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 VERSION_MINOR = 3 -VERSION_BUILD = 3 +VERSION_BUILD = 4 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 9ce60985a4afa530d975bd6e6d5e6b2b04b606d8 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:51:16 +0000 Subject: [PATCH 29/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4265335..6d34827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.3.4a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.4a1) (2026-06-27) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.3a1...1.3.4a1) + +**Merged pull requests:** + +- fix: accept foreign \(ovos-spec-tools / ovos-workshop\) Intents at registration [\#48](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/48) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.3.3a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.3a1) (2026-06-27) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.2a1...1.3.3a1) From 9b87995e431d2b069ad890efceac680e3ff25307 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:51:54 +0100 Subject: [PATCH 30/38] feat: consume OVOS-INTENT-4 keyword registration (alongside legacy) (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: consume OVOS-INTENT-4 keyword registration (alongside legacy) Subscribe to the OVOS-INTENT-4 spec registration topics (ovos.intent.register.keyword, ovos.entity.register, the deregister trio, and intent enable/disable) in addition to the legacy register_vocab / register_intent / detach_* handlers, which are kept untouched so un-migrated skills keep working. The keyword handler translates the consolidated §5 payload into adapt's split vocab + IntentBuilder model: required -> require, optional -> optionally, one_of groups -> one_of, excluded -> exclude. Inline samples are registered as adapt entities (which expand INTENT-1 (a|b)/[opt] templates), namespaced by skill_id so detach-by-skill reaches them. Malformed payloads (no required and no one_of, missing samples) are dropped with a WARN per §5.3. Co-Authored-By: Claude Opus 4.8 * fix(deps): bump to published merged versions (spec-tools 0.16.1a2, allow padacioso 2.0 / workshop 9) * test: add INTENT-4 consumer e2e proving spec-topic register + match Adds test/end2end/test_intent4_consume_e2e.py: boots a real MiniCroft on the adapt pipeline (ovoscope E2EPipelineHarness) and asserts adapt CONSUMES the OVOS-INTENT-4 spec registration topics, not just the legacy bus events: - §5 ovos.intent.register.keyword -> utterance matches (one_of, excluded) - legacy register_vocab/register_intent still matches (back-compat) - §8.2 ovos.intent.deregister / §8.4 ovos.skill.deregister remove the intent - §8.5 ovos.intent.disable suppresses + ovos.intent.enable re-arms - §11 the keyword engine does NOT match a template-topic registration xfail: §8.5 disable is registration-scoped in the spec but adapt scopes it to the disable Message's Session, so a cross-session disable does not suppress. Also fixes a latent crash exposed by the pinned stack: match_intent and the spec disable/enable handlers assumed Session.blacklisted_intents was a list, but ovos-bus-client>=2.4.0a1 defaults it to None -> 'argument of type NoneType is not iterable'. Guarded all three sites. Wires test/end2end/ into the ovoscope workflow and pins the e2e stack floor (ovos-bus-client>=2.4.0a1, ovos-spec-tools>=0.16.1a2) in the test extra. Co-Authored-By: Claude Opus 4.8 * fix(test): pin ovos-workshop 9.0.0a1 to dodge IntentBuilder regression ovos-workshop 9.0.1a2 changed IntentBuilder.build() to return an ovos_spec_tools.intent.Intent that lacks .validate / .validate_with_tags. ovos_adapt.engine.register_intent_parser enforces that duck-type, so under 9.0.1a2 *every* adapt registration (legacy register_intent AND the new INTENT-4 ovos.intent.register.keyword) is rejected with "... is not an intent parser", reddening the whole ovoscope e2e suite. The bus-client>=2.4.0a1 floor drags the latest workshop prerelease (9.0.1a2) in; pin the last good one (9.0.0a1, which still satisfies bus-client>=2.4.0a1) in the test extra until workshop restores the validating Intent. Co-Authored-By: Claude Opus 4.8 * test: use adapt's own IntentBuilder in legacy-flow test test_legacy_flow_still_works imported ovos_workshop.intents.IntentBuilder, but the coverage job installs the package with no extras (install_extras: '') so ovos-workshop is absent there, failing collection. ovos_adapt.intent .IntentBuilder is the adapt-native drop-in (same serialized __dict__), so the test no longer needs ovos-workshop to exercise the legacy register_intent path. Co-Authored-By: Claude Opus 4.8 * fix: declare bus-client runtime floor + lift pm/test floors for 2.x opm.py imports ovos_bus_client at runtime; declare it directly. The prerelease floor (>=2.5.1a1) only resolves without --pre when the ovos-plugin-manager floor is also lifted to the 2.x line (>=2.4.0a1), otherwise pip selects an old pm that caps ovos-bus-client <2.0. Bump the [test] INTENT-4 e2e bus-client floor 2.4.0a1 -> 2.5.1a1. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/ovoscope.yml | 3 +- ovos_adapt/opm.py | 228 ++++++++++++++++++- pyproject.toml | 16 +- test/end2end/test_intent4_consume_e2e.py | 269 +++++++++++++++++++++++ test/test_intent4_spec.py | 179 +++++++++++++++ 5 files changed, 691 insertions(+), 4 deletions(-) create mode 100644 test/end2end/test_intent4_consume_e2e.py create mode 100644 test/test_intent4_spec.py diff --git a/.github/workflows/ovoscope.yml b/.github/workflows/ovoscope.yml index 7ae7c09..030be42 100644 --- a/.github/workflows/ovoscope.yml +++ b/.github/workflows/ovoscope.yml @@ -6,6 +6,7 @@ on: paths: - 'ovos_adapt/**' - 'test/test_ovoscope_e2e.py' + - 'test/end2end/**' - '.github/workflows/ovoscope.yml' workflow_dispatch: @@ -14,7 +15,7 @@ jobs: uses: OpenVoiceOS/gh-automations/.github/workflows/ovoscope.yml@dev with: install_extras: "test" - test_path: "test/test_ovoscope_e2e.py" + test_path: "test/test_ovoscope_e2e.py test/end2end/" require_adapt: true # Validate against the unreleased ovoscope dev branch (E2EPipelineHarness). # Drop once ovoscope is released to PyPI with the harness. diff --git a/ovos_adapt/opm.py b/ovos_adapt/opm.py index 86d2d09..74549dc 100644 --- a/ovos_adapt/opm.py +++ b/ovos_adapt/opm.py @@ -23,12 +23,12 @@ from ovos_bus_client.util import get_message_lang from ovos_config.config import Configuration from ovos_plugin_manager.templates.pipeline import IntentHandlerMatch, ConfidenceMatcherPipeline -from ovos_spec_tools import closest_lang, standardize_lang +from ovos_spec_tools import closest_lang, standardize_lang, SpecMessage from ovos_utils import flatten_list from ovos_utils.fakebus import FakeBus from ovos_utils.log import LOG -from ovos_adapt.intent import open_intent_envelope +from ovos_adapt.intent import open_intent_envelope, IntentBuilder from ovos_adapt.engine import (IntentDeterminationEngine, DomainIntentDeterminationEngine, HierarchicalIntentDeterminationEngine) @@ -83,6 +83,8 @@ def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None, self.bus.on('intent.service.adapt.manifest.get', self.handle_adapt_manifest) self.bus.on('intent.service.adapt.vocab.manifest.get', self.handle_vocab_manifest) + self._register_spec_handlers() + def update_context(self, intent): """Updates context with keyword from the intent. @@ -393,6 +395,226 @@ def handle_vocab_manifest(self, message): self.bus.emit(message.reply("intent.service.adapt.vocab.manifest", {"vocab": self.registered_vocab})) + # ------------------------------------------------------------------ + # OVOS-INTENT-4 spec registration topics (consumed alongside legacy) + # ------------------------------------------------------------------ + def _register_spec_handlers(self): + """Subscribe to the OVOS-INTENT-4 registration topics. + + These run *in addition* to the legacy ``register_vocab`` / + ``register_intent`` / ``detach_*`` handlers — un-migrated skills keep + working unchanged. The spec consolidates vocab + intent into one + ``ovos.intent.register.keyword`` payload (INTENT-4 §5), so this + handler builds the adapt IntentBuilder *and* registers the inline + vocabularies in a single pass. + """ + self.bus.on(SpecMessage.INTENT_REGISTER_KEYWORD, + self.handle_spec_register_keyword) + self.bus.on(SpecMessage.ENTITY_REGISTER, + self.handle_spec_register_entity) + self.bus.on(SpecMessage.INTENT_DEREGISTER, + self.handle_spec_deregister_intent) + self.bus.on(SpecMessage.ENTITY_DEREGISTER, + self.handle_spec_deregister_entity) + self.bus.on(SpecMessage.SKILL_DEREGISTER, + self.handle_spec_deregister_skill) + self.bus.on(SpecMessage.INTENT_ENABLE, + self.handle_spec_enable_intent) + self.bus.on(SpecMessage.INTENT_DISABLE, + self.handle_spec_disable_intent) + + @staticmethod + def _spec_entity_type(skill_id: str, name: str) -> str: + """Namespace a spec vocabulary ``name`` to an adapt entity_type. + + INTENT-4 vocabulary ``name`` is unique within a skill (§5.1); adapt + entity_types are a global namespace. We prefix with the same + normalized skill_id form the legacy detach helpers key off + (:func:`_entity_skill_id`) so ``detach_skill`` reaches these too. + """ + return f"{_entity_skill_id(skill_id)}{name}" + + @staticmethod + def _spec_intent_name(skill_id: str, intent_name: str) -> str: + """Build the adapt intent label (``skill_id:intent_name``). + + Mirrors the legacy convention: skills emit IntentBuilder names of the + form ``:`` so detach-by-skill and the + match-result ``intent_type`` carry the owning skill. + """ + if ":" in intent_name: + return intent_name + return f"{skill_id}:{intent_name}" + + def _register_spec_vocab(self, descriptor: dict, skill_id: str, + lang: str) -> Optional[str]: + """Register one INTENT-4 vocabulary descriptor (§5.1) into adapt. + + Each ``samples`` entry is a slot-free INTENT-1 template; adapt's + ``register_entity`` expands ``(a|b)`` / ``[opt]`` syntax itself, so + samples are passed through verbatim. Returns the namespaced + entity_type, or ``None`` if the descriptor is malformed. + """ + name = descriptor.get("name") + samples = descriptor.get("samples") or [] + if not name or not samples: + return None + entity_type = self._spec_entity_type(skill_id, name) + for sample in samples: + if not sample: + continue + self.register_vocabulary(sample, entity_type, None, None, lang) + self.registered_vocab.append({"entity_value": sample, + "entity_type": entity_type}) + return entity_type + + def handle_spec_register_keyword(self, message): + """Consume ``ovos.intent.register.keyword`` (INTENT-4 §5). + + Translates the consolidated keyword payload into adapt's split + vocab + IntentBuilder model: + + - ``required[]`` -> register_entity(name) + IntentBuilder.require(name) + - ``optional[]`` -> register_entity(name) + .optionally(name) + - ``one_of[][]`` -> register every member + .one_of(*group) + - ``excluded[]`` -> register_entity(name) + .exclude(name) + """ + data = message.data + skill_id = message.context.get("skill_id") or data.get("skill_id") + intent_name = data.get("intent_name") + lang = standardize_lang(data.get("lang") or get_message_lang(message)) + + required = data.get("required") or [] + optional = data.get("optional") or [] + one_of = data.get("one_of") or [] + excluded = data.get("excluded") or [] + + if not skill_id or not intent_name: + LOG.warning(f"ignoring malformed {SpecMessage.INTENT_REGISTER_KEYWORD} " + f"registration (lang={lang}): missing skill_id/intent_name") + return + # INTENT-3 §4.2 / INTENT-4 §5.3: required and one_of MUST NOT both be empty + if not required and not one_of: + LOG.warning(f"ignoring malformed keyword intent " + f"{skill_id}:{intent_name} (lang={lang}, topic=" + f"{SpecMessage.INTENT_REGISTER_KEYWORD}): required and " + f"one_of are both empty") + return + + builder = IntentBuilder(self._spec_intent_name(skill_id, intent_name)) + + for descriptor in required: + entity_type = self._register_spec_vocab(descriptor, skill_id, lang) + if entity_type is None: + LOG.warning(f"ignoring malformed keyword intent " + f"{skill_id}:{intent_name} (lang={lang}): a required " + f"vocabulary descriptor lacks name/samples") + return + builder.require(entity_type) + + for descriptor in optional: + entity_type = self._register_spec_vocab(descriptor, skill_id, lang) + if entity_type is not None: + builder.optionally(entity_type) + + for group in one_of: + members = [] + for descriptor in group: + entity_type = self._register_spec_vocab(descriptor, skill_id, lang) + if entity_type is not None: + members.append(entity_type) + if members: + builder.one_of(*members) + + for descriptor in excluded: + entity_type = self._register_spec_vocab(descriptor, skill_id, lang) + if entity_type is not None: + builder.exclude(entity_type) + + self.register_intent(builder.build()) + + def handle_spec_register_entity(self, message): + """Consume ``ovos.entity.register`` (INTENT-4 §7). + + Registers an ``.entity`` value-set into the adapt trie. Each + ``samples`` entry is a slot-free value (INTENT-1 §5.4). + """ + data = message.data + skill_id = message.context.get("skill_id") or data.get("skill_id") + entity_name = data.get("entity_name") + lang = standardize_lang(data.get("lang") or get_message_lang(message)) + samples = data.get("samples") or [] + if not skill_id or not entity_name or not samples: + LOG.warning(f"ignoring malformed {SpecMessage.ENTITY_REGISTER} " + f"registration (entity_name={entity_name}, lang={lang}): " + f"missing skill_id/entity_name/samples") + return + self._register_spec_vocab({"name": entity_name, "samples": samples}, + skill_id, lang) + + def handle_spec_deregister_intent(self, message): + """Consume ``ovos.intent.deregister`` (INTENT-4 §8.2).""" + data = message.data + skill_id = message.context.get("skill_id") or data.get("skill_id") + intent_name = data.get("intent_name") + if not skill_id or not intent_name: + return + self.detach_intent(self._spec_intent_name(skill_id, intent_name)) + + def handle_spec_deregister_entity(self, message): + """Consume ``ovos.entity.deregister`` (INTENT-4 §8.3).""" + data = message.data + skill_id = message.context.get("skill_id") or data.get("skill_id") + entity_name = data.get("entity_name") + if not skill_id or not entity_name: + return + entity_type = self._spec_entity_type(skill_id, entity_name) + + def match_entity(d): + return d and d[1] == entity_type + + with self.lock: + for lang in self.engines: + self.engines[lang].drop_entity(match_func=match_entity) + + def handle_spec_deregister_skill(self, message): + """Consume ``ovos.skill.deregister`` (INTENT-4 §8.4).""" + data = message.data + skill_id = message.context.get("skill_id") or data.get("skill_id") + if not skill_id: + return + self.detach_skill(skill_id) + + def handle_spec_disable_intent(self, message): + """Consume ``ovos.intent.disable`` (INTENT-4 §8.5). + + Adds the intent to the session blacklist so it is excluded from match + candidacy without losing its registration. + """ + data = message.data + skill_id = message.context.get("skill_id") or data.get("skill_id") + intent_name = data.get("intent_name") + if not skill_id or not intent_name: + return + intent_type = self._spec_intent_name(skill_id, intent_name) + sess = SessionManager.get(message) + if sess.blacklisted_intents is None: + sess.blacklisted_intents = [] + if intent_type not in sess.blacklisted_intents: + sess.blacklisted_intents.append(intent_type) + + def handle_spec_enable_intent(self, message): + """Consume ``ovos.intent.enable`` (INTENT-4 §8.5).""" + data = message.data + skill_id = message.context.get("skill_id") or data.get("skill_id") + intent_name = data.get("intent_name") + if not skill_id or not intent_name: + return + intent_type = self._spec_intent_name(skill_id, intent_name) + sess = SessionManager.get(message) + if intent_type in (sess.blacklisted_intents or []): + sess.blacklisted_intents.remove(intent_type) + def _domain_from_intent_name(intent_name: str) -> str: """Extract skill_id domain from an intent label. @@ -464,6 +686,8 @@ def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None, self.bus.on('intent.service.adapt.manifest.get', self.handle_adapt_manifest) self.bus.on('intent.service.adapt.vocab.manifest.get', self.handle_vocab_manifest) + self._register_spec_handlers() + def _resolve_entity_domain(self, lang: str, entity_type: str) -> str: """Best-effort lookup of the domain that owns an entity_type. diff --git a/pyproject.toml b/pyproject.toml index bcebf15..f45d35a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,21 @@ dependencies = [ ] [project.optional-dependencies] -test = ["pytest", "pytest-cov"] +test = [ + "pytest", + "pytest-cov", + # INTENT-4 consumer e2e stack floor (ovos.intent.register.* topics + + # E2EPipelineHarness namespace flags). Mirrors ovos-test-harness@dev. + "ovos-bus-client>=2.5.1a1", + "ovos-spec-tools>=0.16.1a2", + # ovos-workshop 9.0.1a2 regressed IntentBuilder.build(): it now returns an + # ovos_spec_tools.intent.Intent that lacks .validate / .validate_with_tags, + # which ovos_adapt.engine.register_intent_parser requires — breaking BOTH + # the legacy and the INTENT-4 spec registration paths. Pin the last good + # prerelease (9.0.0a1, which still satisfies bus-client>=2.4.0a1) until the + # upstream regression is fixed. See report / ovos-workshop. + "ovos-workshop==9.0.0a1", +] [project.urls] Homepage = "https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin" diff --git a/test/end2end/test_intent4_consume_e2e.py b/test/end2end/test_intent4_consume_e2e.py new file mode 100644 index 0000000..876d3bc --- /dev/null +++ b/test/end2end/test_intent4_consume_e2e.py @@ -0,0 +1,269 @@ +"""OVOS-INTENT-4 *consumer* end-to-end tests for the Adapt pipeline. + +Where ``test/test_ovoscope_e2e.py`` proves adapt matches intents registered via +the **legacy** ``register_vocab`` / ``register_intent`` bus events, this suite +proves adapt *consumes the INTENT-4 spec registration topics* and then matches — +the bus-level contract of OVOS-INTENT-4 (``ovos-intent-4.md``). + +Adapt is a **keyword** engine, so it consumes ``ovos.intent.register.keyword`` +(§5) and explicitly NOT ``ovos.intent.register.template`` (§11). Each test boots +a real ``MiniCroft`` pinned to the adapt pipeline (via ``E2EPipelineHarness``), +emits the spec registration message on the wire, sends a matching utterance, and +asserts the intent dispatches ``:`` — proving the +registration was consumed off the spec topic, not the legacy path. + +xfail discipline (mirrors the cross-repo ``ovos-test-harness`` conformance +suite): a clause the engine does not yet honour per the spec letter is +``@pytest.mark.xfail(strict=False, reason=...)`` quoting the clause and what the +engine actually does, so it flips to a pass once the impl converges. +""" +import unittest + +import pytest + +ovoscope = pytest.importorskip( + "ovoscope", reason="ovoscope not installed; skipping E2E tests" +) + +from ovoscope import ( # noqa: E402 + E2EPipelineHarness, + make_session, +) +from ovos_bus_client.message import Message # noqa: E402 +from ovos_spec_tools import SpecMessage # noqa: E402 + +from ovos_adapt.opm import AdaptPipeline # noqa: E402 + +PIPELINE_ID = "ovos-adapt-pipeline-plugin" +CONFIG_KEY = "adapt" + +# Spec topics (resolve to the ovos.intent.* / ovos.entity.* strings on the wire). +REGISTER_KEYWORD = str(SpecMessage.INTENT_REGISTER_KEYWORD) +REGISTER_TEMPLATE = str(SpecMessage.INTENT_REGISTER_TEMPLATE) +INTENT_DEREGISTER = str(SpecMessage.INTENT_DEREGISTER) +SKILL_DEREGISTER = str(SpecMessage.SKILL_DEREGISTER) +INTENT_DISABLE = str(SpecMessage.INTENT_DISABLE) +INTENT_ENABLE = str(SpecMessage.INTENT_ENABLE) + + +class _Intent4AdaptHarness(E2EPipelineHarness): + PIPELINE_ID = PIPELINE_ID + CONFIG_KEY = CONFIG_KEY + PLUGIN_CONFIG = {} + SKILL_ID = "intent4_adapt.skill" + + pipeline: AdaptPipeline # type: ignore[assignment] + + def setUp(self): + super().setUp() + # Adapt realises §8.5 disable by mutating the *session* blacklist, and + # the no-session path resolves to the shared SessionManager.default_ + # session singleton — its blacklist leaks across tests. Reset it so a + # prior disable test cannot suppress a later match. + from ovos_bus_client.session import SessionManager + SessionManager.default_session.blacklisted_intents = [] + SessionManager.default_session.blacklisted_skills = [] + + def _register_keyword(self, intent_name, required, *, optional=None, + one_of=None, excluded=None, lang="en-US", settle=1.0): + """Emit a §5 ``ovos.intent.register.keyword`` payload on the bus. + + ``required`` etc. are ``{name: [samples]}`` dicts; this builds the + shape-stable four-key payload the spec mandates (§5.2). + """ + import time + + def _descs(d): + return [{"name": n, "samples": s} for n, s in (d or {}).items()] + + payload = { + "skill_id": self.SKILL_ID, + "intent_name": intent_name, + "lang": lang, + "required": _descs(required), + "optional": _descs(optional), + "one_of": [_descs(g) for g in (one_of or [])], + "excluded": _descs(excluded), + } + self.bus.emit(Message(REGISTER_KEYWORD, payload, + {"skill_id": self.SKILL_ID})) + time.sleep(settle) + + def _emit(self, topic, intent_name=None, settle=0.5, **extra): + import time + data = {"skill_id": self.SKILL_ID, "lang": "en-US"} + if intent_name is not None: + data["intent_name"] = intent_name + data.update(extra) + self.bus.emit(Message(topic, data, {"skill_id": self.SKILL_ID})) + time.sleep(settle) + + +class TestSpecKeywordConsumed(_Intent4AdaptHarness): + """§5: a keyword intent registered on the spec topic becomes matchable.""" + + def test_spec_keyword_registration_is_matchable(self): + """Registering via ``ovos.intent.register.keyword`` makes the intent + matchable (§5) — proving adapt consumed the spec topic, not legacy.""" + self._register_keyword( + "lights_off", + required={"TurnOff": ["off", "disable", "shutdown"], + "Light": ["light", "lights", "lamp"]}, + ) + msg = self.send_and_capture( + "turn off the lights", + expected_types=[f"{self.SKILL_ID}:lights_off"], + ) + self.assertIsNotNone(msg, "expected intent match from spec registration") + self.assertEqual(msg.msg_type, f"{self.SKILL_ID}:lights_off") + self.assertEqual(msg.data.get("utterance"), "turn off the lights") + + def test_spec_one_of_group(self): + """A ``one_of`` group member satisfies the group (§5.2).""" + self._register_keyword( + "lights", + required={"Light": ["light", "lights"]}, + one_of=[{"TurnOn": ["on", "enable"], "TurnOff": ["off", "disable"]}], + ) + msg = self.send_and_capture( + "turn on lights", expected_types=[f"{self.SKILL_ID}:lights"] + ) + self.assertIsNotNone(msg, "one_of group member should satisfy the group") + + def test_spec_excluded_suppresses_match(self): + """An ``excluded`` keyword blocks the match (§5.2).""" + self._register_keyword( + "lights_off", + required={"TurnOff": ["off"], "Light": ["lights"]}, + excluded={"Question": ["what", "how"]}, + ) + self.expect_no_match("what turns off the lights", timeout=3.0) + + +class TestLegacyStillConsumed(_Intent4AdaptHarness): + """Back-compat: the legacy ``register_vocab`` / ``register_intent`` path + still matches alongside the spec topic (memory: handlers run *in addition*).""" + + def test_legacy_keyword_registration_still_matches(self): + from ovoscope import register_adapt_intent, register_adapt_vocab + from ovos_workshop.intents import IntentBuilder + + register_adapt_vocab(self.bus, f"{self.SKILL_ID}:TurnOff", ["off"]) + register_adapt_vocab(self.bus, f"{self.SKILL_ID}:Light", ["lights"]) + register_adapt_intent( + self.bus, + IntentBuilder(f"{self.SKILL_ID}:lights_off") + .require(f"{self.SKILL_ID}:TurnOff") + .require(f"{self.SKILL_ID}:Light"), + ) + msg = self.send_and_capture( + "turn off the lights", + expected_types=[f"{self.SKILL_ID}:lights_off"], + ) + self.assertIsNotNone(msg, "legacy registration must still match") + + +class TestSpecDeregister(_Intent4AdaptHarness): + """§8.2 / §8.4: spec deregistration removes a spec-registered intent.""" + + def test_spec_deregister_removes_intent(self): + """After ``ovos.intent.deregister`` the spec-registered intent no longer + matches (§8.2).""" + self._register_keyword( + "lights_off", + required={"TurnOff": ["off"], "Light": ["lights"]}, + ) + self.assertIsNotNone( + self.send_and_capture("turn off the lights", + expected_types=[f"{self.SKILL_ID}:lights_off"]), + "sanity: intent should match before deregister", + ) + self._emit(INTENT_DEREGISTER, "lights_off") + self.expect_no_match("turn off the lights", timeout=3.0) + + def test_spec_skill_deregister_removes_intent(self): + """``ovos.skill.deregister`` removes the whole skill's intents (§8.4).""" + self._register_keyword( + "lights_off", + required={"TurnOff": ["off"], "Light": ["lights"]}, + ) + self._emit(SKILL_DEREGISTER) + self.expect_no_match("turn off the lights", timeout=3.0) + + +class TestSpecDisableEnable(_Intent4AdaptHarness): + """§8.5: ``ovos.intent.disable`` suppresses, ``ovos.intent.enable`` re-arms. + + DIVERGENCE (real finding): unlike padatious / padacioso / nebulento / + palavreado — which keep a *global* disabled-set keyed on the registration — + adapt realises disable by appending the intent to the **session** + blacklist (``SessionManager.get(message).blacklisted_intents``). It is + therefore scoped to whichever Session the disable Message carries. These + tests drive the *default* session (no session override on either the + disable or the utterance), where ``SessionManager`` returns the shared + ``default_session`` singleton, so the suppression is observable. A disable + addressed to one session would NOT suppress an utterance in another — a + departure from the spec's registration-scoped wording in §8.5. + """ + + def test_spec_disable_suppresses_on_default_session(self): + self._register_keyword( + "lights_off", + required={"TurnOff": ["off"], "Light": ["lights"]}, + ) + self._emit(INTENT_DISABLE, "lights_off") + # default session (no override) — same one the disable mutated + self.expect_no_match("turn off the lights", timeout=3.0) + + def test_spec_enable_rearms_on_default_session(self): + self._register_keyword( + "lights_off", + required={"TurnOff": ["off"], "Light": ["lights"]}, + ) + self._emit(INTENT_DISABLE, "lights_off") + self._emit(INTENT_ENABLE, "lights_off") + msg = self.send_and_capture( + "turn off the lights", + expected_types=[f"{self.SKILL_ID}:lights_off"], + ) + self.assertIsNotNone(msg, "intent should match again after enable") + + @pytest.mark.xfail( + strict=False, + reason="INTENT-4 §8.5: 'plugins exclude it from match candidacy until " + "it is re-enabled' — adapt scopes disable to the disable " + "Message's Session (SessionManager.get(message).blacklisted_" + "intents), so a disable on one session does NOT suppress a " + "match in a different session; the others keep a global set.", + ) + def test_spec_disable_is_global_across_sessions(self): + """A disable with no session must suppress an utterance carrying an + unrelated session (§8.5 is registration-scoped, not session-scoped).""" + self._register_keyword( + "lights_off", + required={"TurnOff": ["off"], "Light": ["lights"]}, + ) + self._emit(INTENT_DISABLE, "lights_off") # default session + other = make_session("intent4-other-session") + self.expect_no_match("turn off the lights", session=other, timeout=3.0) + + +class TestNegativeTemplateTopic(_Intent4AdaptHarness): + """§11: a keyword engine MUST NOT consume the *template* topic.""" + + def test_template_topic_does_not_match_on_keyword_engine(self): + """Registering on ``ovos.intent.register.template`` must not make an + intent matchable in adapt — adapt is a keyword engine (§11).""" + import time + self.bus.emit(Message(REGISTER_TEMPLATE, { + "skill_id": self.SKILL_ID, + "intent_name": "play_music", + "lang": "en-US", + "samples": ["play {query}", "put on {query}"], + }, {"skill_id": self.SKILL_ID})) + time.sleep(0.5) + self.expect_no_match("play the beatles", timeout=3.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_intent4_spec.py b/test/test_intent4_spec.py new file mode 100644 index 0000000..72f52f6 --- /dev/null +++ b/test/test_intent4_spec.py @@ -0,0 +1,179 @@ +# Copyright 2024 OpenVoiceOS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for OVOS-INTENT-4 keyword/entity registration consumption. + +These cover the *new* spec topics (``ovos.intent.register.keyword`` et al.) +consumed alongside the legacy ``register_vocab`` / ``register_intent`` flow. +""" +from unittest import TestCase, mock + +from ovos_bus_client.message import Message +from ovos_spec_tools import SpecMessage + +from ovos_adapt.opm import AdaptPipeline + + +class TestIntent4KeywordRegistration(TestCase): + def setUp(self): + self.pipeline = AdaptPipeline(mock.Mock()) + + def _register_keyword(self, **payload): + msg = Message(SpecMessage.INTENT_REGISTER_KEYWORD, payload, + {"skill_id": payload.get("skill_id")}) + self.pipeline.handle_spec_register_keyword(msg) + + def _match(self, utterance, lang="en-US"): + msg = Message("intent.service.adapt.get", + data={"utterance": utterance, "lang": lang}) + self.pipeline.handle_get_adapt(msg) + return self.pipeline.bus.emit.call_args[0][0].data["intent"] + + def test_keyword_intent_matches_utterance(self): + """Register via ovos.intent.register.keyword, assert an utterance matches.""" + self._register_keyword( + skill_id="lighting.skill", + intent_name="set_brightness", + lang="en-US", + required=[ + {"name": "set", "samples": ["set", "change", "adjust"]}, + {"name": "brightness", "samples": ["brightness", "light level"]}, + ], + optional=[], + one_of=[], + excluded=[], + ) + + intent = self._match("set the brightness") + self.assertIsNotNone(intent) + self.assertEqual(intent["intent_type"], "lighting.skill:set_brightness") + + def test_one_of_group(self): + """A one_of group requires at least one member present.""" + self._register_keyword( + skill_id="lighting.skill", + intent_name="set_brightness", + lang="en-US", + required=[{"name": "set", "samples": ["set", "change", "adjust"]}], + optional=[], + one_of=[[ + {"name": "up", "samples": ["up", "higher", "brighter"]}, + {"name": "down", "samples": ["down", "lower", "dimmer"]}, + ]], + excluded=[], + ) + + self.assertIsNotNone(self._match("set it higher")) + self.assertIsNotNone(self._match("change it lower")) + # required "set" present but no one_of member -> no match + self.assertIsNone(self._match("set it")) + + def test_excluded_suppresses_match(self): + """An excluded vocabulary suppresses the match when present.""" + self._register_keyword( + skill_id="lighting.skill", + intent_name="set_brightness", + lang="en-US", + required=[{"name": "brightness", "samples": ["brightness"]}], + optional=[], + one_of=[], + excluded=[{"name": "question", "samples": ["what is", "how"]}], + ) + self.assertIsNotNone(self._match("brightness")) + self.assertIsNone(self._match("what is the brightness")) + + def test_malformed_no_required_no_one_of_rejected(self): + """required and one_of both empty is malformed (INTENT-4 §5.3).""" + self._register_keyword( + skill_id="lighting.skill", + intent_name="bad_intent", + lang="en-US", + required=[], + optional=[{"name": "x", "samples": ["x"]}], + one_of=[], + excluded=[], + ) + # nothing registered -> no match + self.assertIsNone(self._match("x")) + + def test_template_expansion_in_samples(self): + """INTENT-1 (a|b) / [opt] template syntax in samples is expanded.""" + self._register_keyword( + skill_id="lighting.skill", + intent_name="lights", + lang="en-US", + required=[{"name": "action", + "samples": ["turn (on|off) the [bright] lights"]}], + optional=[], + one_of=[], + excluded=[], + ) + self.assertIsNotNone(self._match("turn off the bright lights")) + self.assertIsNotNone(self._match("turn on the lights")) + + def test_legacy_flow_still_works(self): + """Legacy register_vocab/register_intent path is untouched.""" + from ovos_adapt.intent import IntentBuilder + self.pipeline.handle_register_vocab( + Message("register_vocab", + {"entity_value": "test", "entity_type": "testKeyword"})) + self.pipeline.handle_register_intent( + Message("register_intent", + IntentBuilder("skill:testIntent").require("testKeyword").__dict__)) + intent = self._match("test") + self.assertIsNotNone(intent) + self.assertEqual(intent["intent_type"], "skill:testIntent") + + +class TestIntent4Deregistration(TestCase): + def setUp(self): + self.pipeline = AdaptPipeline(mock.Mock()) + self._register() + + def _register(self): + msg = Message(SpecMessage.INTENT_REGISTER_KEYWORD, + {"skill_id": "lighting.skill", + "intent_name": "set_brightness", + "lang": "en-US", + "required": [{"name": "brightness", + "samples": ["brightness"]}], + "optional": [], "one_of": [], "excluded": []}, + {"skill_id": "lighting.skill"}) + self.pipeline.handle_spec_register_keyword(msg) + + def _match(self, utterance, lang="en-US"): + # match_intent is lru_cached; clear so re-matching after a + # deregistration re-runs the engine instead of returning a stale hit. + self.pipeline.match_intent.cache_clear() + msg = Message("intent.service.adapt.get", + data={"utterance": utterance, "lang": lang}) + self.pipeline.handle_get_adapt(msg) + return self.pipeline.bus.emit.call_args[0][0].data["intent"] + + def test_deregister_intent(self): + self.assertIsNotNone(self._match("brightness")) + self.pipeline.handle_spec_deregister_intent( + Message(SpecMessage.INTENT_DEREGISTER, + {"skill_id": "lighting.skill", + "intent_name": "set_brightness", "lang": "en-US"}, + {"skill_id": "lighting.skill"})) + self.assertIsNone(self._match("brightness")) + + def test_deregister_skill(self): + self.assertIsNotNone(self._match("brightness")) + self.pipeline.handle_spec_deregister_skill( + Message(SpecMessage.SKILL_DEREGISTER, + {"skill_id": "lighting.skill"}, + {"skill_id": "lighting.skill"})) + self.assertIsNone(self._match("brightness")) From 72754af42ada581d77a12f374b6c41c06f058d38 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:52:05 +0000 Subject: [PATCH 31/38] Increment Version to 1.4.0a1 --- ovos_adapt/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 33380f4..d74ce4a 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 -VERSION_MINOR = 3 -VERSION_BUILD = 4 +VERSION_MINOR = 4 +VERSION_BUILD = 0 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 24363159f73cad0bcc5586a2b2959219fbbfb034 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:52:30 +0000 Subject: [PATCH 32/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d34827..ead5028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.4.0a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.4.0a1) (2026-06-27) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.4a1...1.4.0a1) + +**Merged pull requests:** + +- feat: consume OVOS-INTENT-4 keyword registration \(alongside legacy\) [\#44](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/44) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.3.4a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.3.4a1) (2026-06-27) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.3a1...1.3.4a1) From cd0e3644e6c071b6f3d5d7d1a32f52ca2f02f4c2 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:07:47 +0100 Subject: [PATCH 33/38] fix: default config value (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: default config value related PR: https://github.com/OpenVoiceOS/ovos-config/pull/264 * fix: restore core_config reference broken by the config-default change The __init__ change replaced 'core_config = Configuration()' with an 'intents'-scoped lookup but left lines reading core_config.get('lang') / .get('secondary_langs') → NameError. Keep both: core_config for lang/secondary_langs, intent_config for the plugin's own config block. --- ovos_adapt/opm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ovos_adapt/opm.py b/ovos_adapt/opm.py index 74549dc..375a360 100644 --- a/ovos_adapt/opm.py +++ b/ovos_adapt/opm.py @@ -55,7 +55,8 @@ class AdaptPipeline(ConfidenceMatcherPipeline): def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None, config: Optional[Dict] = None): core_config = Configuration() - config = config or core_config.get("context", {}) # legacy mycroft-core path + intent_config = core_config.get('intents', {}) + config = config or intent_config.get("ovos-adapt-pipeline-plugin") or intent_config.get("adapt") or dict() super().__init__(bus, config) self.lang = standardize_lang(core_config.get("lang", "en-US")) langs = core_config.get('secondary_langs') or [] From cfa67c69c3e17bad606abc4a8e328c41906936d2 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:08:01 +0000 Subject: [PATCH 34/38] Increment Version to 1.4.1a1 --- ovos_adapt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index d74ce4a..98fd3af 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 VERSION_MINOR = 4 -VERSION_BUILD = 0 +VERSION_BUILD = 1 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 8902d583b0116cb20c929b7e994132d273a44308 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:08:24 +0000 Subject: [PATCH 35/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ead5028..839e418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.4.1a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.4.1a1) (2026-06-28) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.4.0a1...1.4.1a1) + +**Merged pull requests:** + +- fix: default config value [\#30](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/30) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.4.0a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.4.0a1) (2026-06-27) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.3.4a1...1.4.0a1) From 464062cbcc5b10cbeb7fdf14802e1c97d3984dc0 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:15:01 +0100 Subject: [PATCH 36/38] fix: lift ovos-spec-tools upper bound (spec-tools 1.x) (#53) spec-tools crossed to 1.x; bus-client 2.6.0a1 and the rest of the stack now require ovos-spec-tools>=1.1.0a1. Drop the <1.0.0 cap (keep the floor) so the stack resolves. Co-authored-by: Claude Opus 4.8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f45d35a..4a2302c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "ovos-plugin-manager>=2.4.0a1,<3.0.0", "ovos-utils>=0.3.4,<1.0.0", "ovos_bus_client>=2.5.1a1,<3.0.0", - "ovos-spec-tools>=0.16.1a2,<1.0.0", + "ovos-spec-tools>=0.16.1a2", "langcodes", ] From 8001d125d24b92a0e767d3a601a9f160009ee41f Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:15:11 +0000 Subject: [PATCH 37/38] Increment Version to 1.4.2a1 --- ovos_adapt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_adapt/version.py b/ovos_adapt/version.py index 98fd3af..bdf4bd4 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 VERSION_MINOR = 4 -VERSION_BUILD = 1 +VERSION_BUILD = 2 VERSION_ALPHA = 1 # END_VERSION_BLOCK From c6fb54acc34acf0f018e8493e853c89179119467 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:15:32 +0000 Subject: [PATCH 38/38] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 839e418..82444ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.4.2a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.4.2a1) (2026-06-28) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.4.1a1...1.4.2a1) + +**Merged pull requests:** + +- fix: lift ovos-spec-tools upper bound \(spec-tools 1.x\) [\#53](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/53) ([JarbasAl](https://github.com/JarbasAl)) + ## [1.4.1a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.4.1a1) (2026-06-28) [Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.4.0a1...1.4.1a1)