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..030be42 --- /dev/null +++ b/.github/workflows/ovoscope.yml @@ -0,0 +1,22 @@ +name: E2E Tests (ovoscope) + +on: + pull_request: + branches: [dev] + paths: + - 'ovos_adapt/**' + - 'test/test_ovoscope_e2e.py' + - 'test/end2end/**' + - '.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 test/end2end/" + 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/CHANGELOG.md b/CHANGELOG.md index 6fe8ee6..82444ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,100 @@ # Changelog -## [1.0.9a1](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/tree/1.0.9a1) (2025-11-05) +## [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.0.8...1.0.9a1) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/compare/1.4.1a1...1.4.2a1) **Merged pull requests:** -- Update requirements.txt [\#28](https://github.com/OpenVoiceOS/ovos-adapt-pipeline-plugin/pull/28) ([JarbasAl](https://github.com/JarbasAl)) +- 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) + +**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) + +**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) + +**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) + +**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) + +**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) + +**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) + +**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) + +**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) + +**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) + +**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) + +**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)) 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/README.md b/README.md index 2c27d44..6de7218 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,30 @@ 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 +============= +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 +- [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/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..c5b901b --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,82 @@ +# Configuration + +## Entry points + +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 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 + +`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..dba22e4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,62 @@ +# 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. [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 + +```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..d613405 --- /dev/null +++ b/docs/internals.md @@ -0,0 +1,136 @@ +# 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). + +## 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 + +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/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/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. diff --git a/ovos_adapt/engine.py b/ovos_adapt/engine.py index 76130ea..0359a1a 100644 --- a/ovos_adapt/engine.py +++ b/ovos_adapt/engine.py @@ -13,16 +13,53 @@ # 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 from ovos_adapt.tools.text.trie import Trie +from ovos_adapt.intent import Intent __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 +191,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): @@ -186,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: @@ -488,3 +552,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/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 b684663..375a360 100644 --- a/ovos_adapt/opm.py +++ b/ovos_adapt/opm.py @@ -17,20 +17,21 @@ 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, SpecMessage 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 -from ovos_adapt.engine import IntentDeterminationEngine +from ovos_adapt.intent import open_intent_envelope, IntentBuilder +from ovos_adapt.engine import (IntentDeterminationEngine, + DomainIntentDeterminationEngine, + HierarchicalIntentDeterminationEngine) def _entity_skill_id(skill_id): @@ -54,13 +55,14 @@ 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_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} @@ -82,6 +84,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. @@ -177,8 +181,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 @@ -215,14 +219,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, @@ -238,8 +235,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) @@ -321,7 +318,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): @@ -396,3 +395,476 @@ 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. + + 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(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(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) + + 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. + + 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 or []) \ + and skill not in (sess.blacklisted_skills or []): + 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 = self._get_closest_lang(lang) + if lang is not None: + 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 = 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) + 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/ovos_adapt/version.py b/ovos_adapt/version.py index 2d1ec06..bdf4bd4 100644 --- a/ovos_adapt/version.py +++ b/ovos_adapt/version.py @@ -1,6 +1,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 1 -VERSION_MINOR = 0 -VERSION_BUILD = 9 -VERSION_ALPHA = 0 +VERSION_MINOR = 4 +VERSION_BUILD = 2 +VERSION_ALPHA = 1 # END_VERSION_BLOCK + +__version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4a2302c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[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>=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", + "langcodes", +] + +[project.optional-dependencies] +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" + +[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 = ["."] +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 f17de53..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,<8.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/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_domain_pipeline.py b/test/test_domain_pipeline.py new file mode 100644 index 0000000..2923968 --- /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_adapt.intent 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, []) 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")) diff --git a/test/test_intent_service.py b/test/test_intent_service.py index 1957d54..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 @@ -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.""" diff --git a/test/test_ovoscope_e2e.py b/test/test_ovoscope_e2e.py new file mode 100644 index 0000000..144cb6b --- /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_adapt.intent 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() 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()